mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Merge branch 'main' into fea/music
This commit is contained in:
commit
cd06c0380e
122 changed files with 8844 additions and 1446 deletions
27
.github/workflows/pr.yml
vendored
27
.github/workflows/pr.yml
vendored
|
|
@ -44,19 +44,18 @@ jobs:
|
||||||
id: buildapp
|
id: buildapp
|
||||||
run: |
|
run: |
|
||||||
./gradlew clean assembleDebug testDebugUnitTest --no-daemon
|
./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"
|
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: |
|
run: |
|
||||||
tar -czf build.tgz ./app/.
|
git apply app/src/patches/play_store.patch
|
||||||
- uses: actions/upload-artifact@v6
|
git diff
|
||||||
id: upload-build-dirs
|
|
||||||
with:
|
|
||||||
name: "${{ env.BUILD_DIRS_ARTIFACT }}"
|
|
||||||
path: build.tgz
|
|
||||||
if-no-files-found: error
|
|
||||||
- uses: actions/upload-artifact@v6
|
|
||||||
with:
|
|
||||||
name: APKs
|
|
||||||
path: "${{ steps.buildapp.outputs.apks }}"
|
|
||||||
compression-level: 0
|
|
||||||
|
|
|
||||||
30
.github/workflows/release.yml
vendored
30
.github/workflows/release.yml
vendored
|
|
@ -39,11 +39,24 @@ jobs:
|
||||||
SIGNING_KEY: "${{ secrets.SIGNING_KEY }}"
|
SIGNING_KEY: "${{ secrets.SIGNING_KEY }}"
|
||||||
run: |
|
run: |
|
||||||
./gradlew clean assembleRelease --no-daemon
|
./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
|
- name: Verify signatures
|
||||||
run: |
|
run: |
|
||||||
echo "Verify APK signatures"
|
echo "Verify APK/AAB signatures"
|
||||||
find app/build/outputs/apk -name '*.apk'
|
find app/build/outputs \( -name '*.apk' -or -name '*.aab' \)
|
||||||
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' \) -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
|
- name: Copy APK to shorter names
|
||||||
id: apks
|
id: apks
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -59,11 +72,18 @@ jobs:
|
||||||
- name: Checksums
|
- name: Checksums
|
||||||
run: |
|
run: |
|
||||||
echo "SHA256 checksums:"
|
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@v6
|
||||||
|
with:
|
||||||
|
name: AAB
|
||||||
|
path: |
|
||||||
|
app/build/outputs/bundle/**/*.aab
|
||||||
|
compression-level: 0
|
||||||
- name: Create GitHub release
|
- name: Create GitHub release
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
gh release create "${{ env.TAG_NAME }}" \
|
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"
|
"app/build/outputs/apk/**/*.apk"
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin
|
||||||
|
|
||||||
### User interface
|
### 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
|
- 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
|
- Integration with [Jellyseerr](https://github.com/seerr-team/seerr) to discover new movies and TV shows
|
||||||
- Option to combine Continue Watching & Next Up rows
|
- Option to combine Continue Watching & Next Up rows
|
||||||
|
|
@ -112,6 +114,10 @@ You can [help translate Wholphin](https://translate.codeberg.org/engage/wholphin
|
||||||
|
|
||||||
## Additional screenshots
|
## Additional screenshots
|
||||||
|
|
||||||
|
### Customized home page
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
### Movie library browsing
|
### Movie library browsing
|
||||||
<img width="1280" height="771" alt="0 3 0_movies" src="https://github.com/user-attachments/assets/a49829b5-bc2c-4af9-8d5d-2f7d0973ce01" />
|
<img width="1280" height="771" alt="0 3 0_movies" src="https://github.com/user-attachments/assets/a49829b5-bc2c-4af9-8d5d-2f7d0973ce01" />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
|
||||||
alias(libs.plugins.ksp)
|
alias(libs.plugins.ksp)
|
||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
alias(libs.plugins.hilt)
|
alias(libs.plugins.hilt)
|
||||||
|
|
@ -48,39 +47,6 @@ android {
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
signingConfigs {
|
||||||
if (shouldSign) {
|
if (shouldSign) {
|
||||||
create("ci") {
|
create("ci") {
|
||||||
|
|
@ -98,14 +64,15 @@ android {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
|
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro",
|
"proguard-rules.pro",
|
||||||
)
|
)
|
||||||
|
isDebuggable = false
|
||||||
if (shouldSign) {
|
if (shouldSign) {
|
||||||
signingConfig = signingConfigs.getByName("ci")
|
signingConfig = signingConfigs.getByName("ci")
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -120,10 +87,11 @@ android {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
debug {
|
debug {
|
||||||
if (shouldSign) {
|
isMinifyEnabled = false
|
||||||
signingConfig = signingConfigs.getByName("ci")
|
isDebuggable = true
|
||||||
}
|
applicationIdSuffix = ".debug"
|
||||||
}
|
}
|
||||||
|
|
||||||
applicationVariants.all {
|
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 {
|
splits {
|
||||||
abi {
|
abi {
|
||||||
|
|
@ -150,7 +137,7 @@ android {
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
getByName("main") {
|
getByName("main") {
|
||||||
kotlin.srcDirs("$buildDir/generated/seerr_api/src/main/kotlin")
|
kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -123,6 +123,9 @@ class MainActivity : AppCompatActivity() {
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var suggestionsSchedulerService: SuggestionsSchedulerService
|
lateinit var suggestionsSchedulerService: SuggestionsSchedulerService
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var backdropService: BackdropService
|
||||||
|
|
||||||
// Note: unused but injected to ensure it is created
|
// Note: unused but injected to ensure it is created
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var serverEventListener: ServerEventListener
|
lateinit var serverEventListener: ServerEventListener
|
||||||
|
|
@ -247,6 +250,9 @@ class MainActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
is SetupDestination.AppContent -> {
|
is SetupDestination.AppContent -> {
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
backdropService.clearBackdrop()
|
||||||
|
}
|
||||||
val current = key.current
|
val current = key.current
|
||||||
ProvideLocalClock {
|
ProvideLocalClock {
|
||||||
if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) {
|
if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) {
|
||||||
|
|
@ -409,7 +415,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun changeDisplayMode(modeId: Int) {
|
fun changeDisplayMode(modeId: Int) {
|
||||||
lifecycleScope.launch(Dispatchers.Main + ExceptionHandler()) {
|
lifecycleScope.launch(Dispatchers.Main + ExceptionHandler(autoToast = true)) {
|
||||||
val attrs = window.attributes
|
val attrs = window.attributes
|
||||||
if (attrs.preferredDisplayModeId != modeId) {
|
if (attrs.preferredDisplayModeId != modeId) {
|
||||||
Timber.d("Switch preferredDisplayModeId to %s", modeId)
|
Timber.d("Switch preferredDisplayModeId to %s", modeId)
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ import java.util.UUID
|
||||||
SeerrUser::class,
|
SeerrUser::class,
|
||||||
|
|
||||||
],
|
],
|
||||||
version = 30,
|
version = 31,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
autoMigrations = [
|
autoMigrations = [
|
||||||
AutoMigration(3, 4),
|
AutoMigration(3, 4),
|
||||||
|
|
@ -54,6 +54,7 @@ import java.util.UUID
|
||||||
AutoMigration(11, 12),
|
AutoMigration(11, 12),
|
||||||
AutoMigration(12, 20),
|
AutoMigration(12, 20),
|
||||||
AutoMigration(20, 30),
|
AutoMigration(20, 30),
|
||||||
|
AutoMigration(30, 31),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@TypeConverters(Converters::class)
|
@TypeConverters(Converters::class)
|
||||||
|
|
|
||||||
|
|
@ -1,101 +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<NavDrawerItem> {
|
|
||||||
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,
|
|
||||||
NavDrawerItem.NowPlaying,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
listOf(NavDrawerItem.Favorites, NavDrawerItem.NowPlaying)
|
|
||||||
}
|
|
||||||
|
|
||||||
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<NavDrawerItem>): List<NavDrawerItem> {
|
|
||||||
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<NavDrawerPinnedItem>.isPinned(id: String) = (firstOrNull { it.itemId == id }?.type ?: NavPinType.PINNED) == NavPinType.PINNED
|
|
||||||
|
|
@ -18,6 +18,7 @@ import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import org.jellyfin.sdk.Jellyfin
|
import org.jellyfin.sdk.Jellyfin
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
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.systemApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.userApi
|
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||||
import org.jellyfin.sdk.model.api.AuthenticationResult
|
import org.jellyfin.sdk.model.api.AuthenticationResult
|
||||||
|
|
@ -265,6 +266,17 @@ class ServerRepository
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun authorizeQuickConnect(code: String): Boolean =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
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 {
|
companion object {
|
||||||
fun getServerSharedPreferences(context: Context): SharedPreferences =
|
fun getServerSharedPreferences(context: Context): SharedPreferences =
|
||||||
context.getSharedPreferences(
|
context.getSharedPreferences(
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import java.util.UUID
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|
@ -32,6 +33,8 @@ import kotlin.time.Duration
|
||||||
data class BaseItem(
|
data class BaseItem(
|
||||||
val data: BaseItemDto,
|
val data: BaseItemDto,
|
||||||
val useSeriesForPrimary: Boolean,
|
val useSeriesForPrimary: Boolean,
|
||||||
|
val imageUrlOverride: String? = null,
|
||||||
|
val destinationOverride: Destination? = null,
|
||||||
) : CardGridItem {
|
) : CardGridItem {
|
||||||
val id get() = data.id
|
val id get() = data.id
|
||||||
|
|
||||||
|
|
@ -165,6 +168,7 @@ data class BaseItem(
|
||||||
}?.toIntOrNull()
|
}?.toIntOrNull()
|
||||||
|
|
||||||
fun destination(index: Int? = null): Destination {
|
fun destination(index: Int? = null): Destination {
|
||||||
|
if (destinationOverride != null) return destinationOverride
|
||||||
val result =
|
val result =
|
||||||
// Redirect episodes & seasons to their series if possible
|
// Redirect episodes & seasons to their series if possible
|
||||||
when (type) {
|
when (type) {
|
||||||
|
|
@ -186,6 +190,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 -> {
|
else -> {
|
||||||
Destination.MediaItem(this)
|
Destination.MediaItem(this)
|
||||||
}
|
}
|
||||||
|
|
@ -214,3 +237,28 @@ data class BaseItemUi(
|
||||||
val episodeUnplayedCornerText: String?,
|
val episodeUnplayedCornerText: String?,
|
||||||
val quickDetails: AnnotatedString,
|
val quickDetails: AnnotatedString,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun createGenreDestination(
|
||||||
|
genreId: UUID,
|
||||||
|
genreName: String,
|
||||||
|
parentId: UUID,
|
||||||
|
parentName: String?,
|
||||||
|
includeItemTypes: List<BaseItemKind>?,
|
||||||
|
) = Destination.FilteredCollection(
|
||||||
|
itemId = parentId,
|
||||||
|
filter =
|
||||||
|
CollectionFolderFilter(
|
||||||
|
nameOverride =
|
||||||
|
listOfNotNull(
|
||||||
|
genreName,
|
||||||
|
parentName,
|
||||||
|
).joinToString(" "),
|
||||||
|
filter =
|
||||||
|
GetItemsFilter(
|
||||||
|
genres = listOf(genreId),
|
||||||
|
includeItemTypes = includeItemTypes,
|
||||||
|
),
|
||||||
|
useSavedLibraryDisplayInfo = false,
|
||||||
|
),
|
||||||
|
recursive = true,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ data class Chapter(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}.orEmpty()
|
}?.sortedBy { it.position }
|
||||||
|
.orEmpty()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<HomeRowConfig>,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.github.damontecres.wholphin.data.model
|
package com.github.damontecres.wholphin.data.model
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.ForeignKey
|
import androidx.room.ForeignKey
|
||||||
|
|
||||||
|
|
@ -24,4 +25,5 @@ data class NavDrawerPinnedItem(
|
||||||
val userId: Int,
|
val userId: Int,
|
||||||
val itemId: String,
|
val itemId: String,
|
||||||
val type: NavPinType,
|
val type: NavPinType,
|
||||||
|
@ColumnInfo(defaultValue = "-1") val order: Int,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -656,6 +656,13 @@ sealed interface AppPreference<Pref, T> {
|
||||||
setter = { prefs, _ -> prefs },
|
setter = { prefs, _ -> prefs },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val CustomizeHome =
|
||||||
|
AppDestinationPreference<AppPreferences>(
|
||||||
|
title = R.string.customize_home,
|
||||||
|
destination = Destination.HomeSettings,
|
||||||
|
summary = R.string.customize_home_summary,
|
||||||
|
)
|
||||||
|
|
||||||
val SendCrashReports =
|
val SendCrashReports =
|
||||||
AppSwitchPreference<AppPreferences>(
|
AppSwitchPreference<AppPreferences>(
|
||||||
title = R.string.send_crash_reports,
|
title = R.string.send_crash_reports,
|
||||||
|
|
@ -943,6 +950,14 @@ sealed interface AppPreference<Pref, T> {
|
||||||
setter = { prefs, _ -> prefs },
|
setter = { prefs, _ -> prefs },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val QuickConnect =
|
||||||
|
AppClickablePreference<AppPreferences>(
|
||||||
|
title = R.string.quick_connect,
|
||||||
|
summary = R.string.quick_connect_summary,
|
||||||
|
getter = { },
|
||||||
|
setter = { prefs, _ -> prefs },
|
||||||
|
)
|
||||||
|
|
||||||
val SlideshowDuration =
|
val SlideshowDuration =
|
||||||
AppSliderPreference<AppPreferences>(
|
AppSliderPreference<AppPreferences>(
|
||||||
title = R.string.slideshow_duration,
|
title = R.string.slideshow_duration,
|
||||||
|
|
@ -992,10 +1007,6 @@ val basicPreferences =
|
||||||
preferences =
|
preferences =
|
||||||
listOf(
|
listOf(
|
||||||
AppPreference.SignInAuto,
|
AppPreference.SignInAuto,
|
||||||
AppPreference.HomePageItems,
|
|
||||||
AppPreference.CombineContinueNext,
|
|
||||||
AppPreference.RewatchNextUp,
|
|
||||||
AppPreference.MaxDaysNextUp,
|
|
||||||
AppPreference.PlayThemeMusic,
|
AppPreference.PlayThemeMusic,
|
||||||
AppPreference.RememberSelectedTab,
|
AppPreference.RememberSelectedTab,
|
||||||
AppPreference.SubtitleStyle,
|
AppPreference.SubtitleStyle,
|
||||||
|
|
@ -1026,6 +1037,7 @@ val basicPreferences =
|
||||||
preferences =
|
preferences =
|
||||||
listOf(
|
listOf(
|
||||||
AppPreference.RequireProfilePin,
|
AppPreference.RequireProfilePin,
|
||||||
|
AppPreference.CustomizeHome,
|
||||||
AppPreference.UserPinnedNavDrawerItems,
|
AppPreference.UserPinnedNavDrawerItems,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -1091,6 +1103,7 @@ val advancedPreferences =
|
||||||
preferences =
|
preferences =
|
||||||
listOf(
|
listOf(
|
||||||
AppPreference.ShowClock,
|
AppPreference.ShowClock,
|
||||||
|
AppPreference.CombineContinueNext,
|
||||||
// Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418
|
// Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418
|
||||||
// AppPreference.NavDrawerSwitchOnFocus,
|
// AppPreference.NavDrawerSwitchOnFocus,
|
||||||
AppPreference.ControllerTimeout,
|
AppPreference.ControllerTimeout,
|
||||||
|
|
@ -1168,6 +1181,7 @@ val advancedPreferences =
|
||||||
title = R.string.more,
|
title = R.string.more,
|
||||||
preferences =
|
preferences =
|
||||||
listOf(
|
listOf(
|
||||||
|
AppPreference.QuickConnect,
|
||||||
AppPreference.SendAppLogs,
|
AppPreference.SendAppLogs,
|
||||||
AppPreference.SendCrashReports,
|
AppPreference.SendCrashReports,
|
||||||
AppPreference.DebugLogging,
|
AppPreference.DebugLogging,
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -47,7 +48,12 @@ class BackdropService
|
||||||
|
|
||||||
suspend fun submit(item: BaseItem) =
|
suspend fun submit(item: BaseItem) =
|
||||||
withContext(Dispatchers.IO) {
|
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)
|
submit(item.id.toString(), imageUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -30,6 +30,9 @@ class ImageUrlService
|
||||||
useSeriesForPrimary: Boolean,
|
useSeriesForPrimary: Boolean,
|
||||||
imageTags: Map<ImageType, String?>,
|
imageTags: Map<ImageType, String?>,
|
||||||
imageType: ImageType,
|
imageType: ImageType,
|
||||||
|
parentThumbId: UUID? = null,
|
||||||
|
parentBackdropId: UUID? = null,
|
||||||
|
backdropTags: List<String> = emptyList(),
|
||||||
fillWidth: Int? = null,
|
fillWidth: Int? = null,
|
||||||
fillHeight: Int? = null,
|
fillHeight: Int? = null,
|
||||||
): String? =
|
): String? =
|
||||||
|
|
@ -54,8 +57,65 @@ class ImageUrlService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.PRIMARY,
|
||||||
ImageType.THUMB,
|
|
||||||
ImageType.BANNER,
|
ImageType.BANNER,
|
||||||
-> {
|
-> {
|
||||||
if (useSeriesForPrimary && seriesId != null &&
|
if (useSeriesForPrimary && seriesId != null &&
|
||||||
|
|
@ -99,15 +159,19 @@ class ImageUrlService
|
||||||
imageType: ImageType,
|
imageType: ImageType,
|
||||||
fillWidth: Int? = null,
|
fillWidth: Int? = null,
|
||||||
fillHeight: Int? = null,
|
fillHeight: Int? = null,
|
||||||
|
useSeriesForPrimary: Boolean? = null,
|
||||||
): String? =
|
): String? =
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
getItemImageUrl(
|
getItemImageUrl(
|
||||||
itemId = item.id,
|
itemId = item.id,
|
||||||
itemType = item.type,
|
itemType = item.type,
|
||||||
seriesId = item.data.seriesId,
|
seriesId = item.data.seriesId,
|
||||||
useSeriesForPrimary = item.useSeriesForPrimary,
|
useSeriesForPrimary = useSeriesForPrimary ?: item.useSeriesForPrimary,
|
||||||
imageTags = item.data.imageTags.orEmpty(),
|
imageTags = item.data.imageTags.orEmpty(),
|
||||||
imageType = imageType,
|
imageType = imageType,
|
||||||
|
parentThumbId = item.data.parentThumbItemId,
|
||||||
|
parentBackdropId = item.data.parentBackdropItemId,
|
||||||
|
backdropTags = item.data.backdropImageTags.orEmpty(),
|
||||||
fillWidth = fillWidth,
|
fillWidth = fillWidth,
|
||||||
fillHeight = fillHeight,
|
fillHeight = fillHeight,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@ import android.content.Context
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||||
import com.github.damontecres.wholphin.ui.main.LatestData
|
|
||||||
import com.github.damontecres.wholphin.ui.main.supportedLatestCollectionTypes
|
|
||||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||||
import com.github.damontecres.wholphin.util.supportItemKinds
|
import com.github.damontecres.wholphin.util.supportItemKinds
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
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.userLibraryApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.CollectionType
|
||||||
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
import org.jellyfin.sdk.model.api.UserDto
|
import org.jellyfin.sdk.model.api.UserDto
|
||||||
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
||||||
|
|
@ -44,6 +44,7 @@ class LatestNextUpService
|
||||||
userId: UUID,
|
userId: UUID,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
includeEpisodes: Boolean,
|
includeEpisodes: Boolean,
|
||||||
|
useSeriesForPrimary: Boolean = true,
|
||||||
): List<BaseItem> {
|
): List<BaseItem> {
|
||||||
val request =
|
val request =
|
||||||
GetResumeItemsRequest(
|
GetResumeItemsRequest(
|
||||||
|
|
@ -60,13 +61,19 @@ class LatestNextUpService
|
||||||
remove(BaseItemKind.EPISODE)
|
remove(BaseItemKind.EPISODE)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
enableImageTypes =
|
||||||
|
listOf(
|
||||||
|
ImageType.PRIMARY,
|
||||||
|
ImageType.THUMB,
|
||||||
|
ImageType.BACKDROP,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
val items =
|
val items =
|
||||||
api.itemsApi
|
api.itemsApi
|
||||||
.getResumeItems(request)
|
.getResumeItems(request)
|
||||||
.content
|
.content
|
||||||
.items
|
.items
|
||||||
.map { BaseItem.from(it, api, true) }
|
.map { BaseItem.from(it, api, useSeriesForPrimary) }
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,6 +83,7 @@ class LatestNextUpService
|
||||||
enableRewatching: Boolean,
|
enableRewatching: Boolean,
|
||||||
enableResumable: Boolean,
|
enableResumable: Boolean,
|
||||||
maxDays: Int,
|
maxDays: Int,
|
||||||
|
useSeriesForPrimary: Boolean = true,
|
||||||
): List<BaseItem> {
|
): List<BaseItem> {
|
||||||
val nextUpDateCutoff =
|
val nextUpDateCutoff =
|
||||||
maxDays.takeIf { it > 0 }?.let { LocalDateTime.now().minusDays(it.toLong()) }
|
maxDays.takeIf { it > 0 }?.let { LocalDateTime.now().minusDays(it.toLong()) }
|
||||||
|
|
@ -96,7 +104,7 @@ class LatestNextUpService
|
||||||
.getNextUp(request)
|
.getNextUp(request)
|
||||||
.content
|
.content
|
||||||
.items
|
.items
|
||||||
.map { BaseItem.from(it, api, true) }
|
.map { BaseItem.from(it, api, useSeriesForPrimary) }
|
||||||
return nextUp
|
return nextUp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -192,3 +200,17 @@ class LatestNextUpService
|
||||||
return@withContext result
|
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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,184 @@
|
||||||
|
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.util.supportedCollectionTypes
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
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<NavDrawerItemState> = _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)
|
||||||
|
}
|
||||||
|
}.launchIn(coroutineScope)
|
||||||
|
seerrServerRepository.active
|
||||||
|
.onEach { discoverActive ->
|
||||||
|
_state.update { it.copy(discoverEnabled = discoverActive) }
|
||||||
|
}.launchIn(coroutineScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getAllUserLibraries(
|
||||||
|
userId: UUID,
|
||||||
|
tvAccess: Boolean,
|
||||||
|
): List<Library> {
|
||||||
|
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<Library> {
|
||||||
|
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 =
|
||||||
|
serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId }
|
||||||
|
|
||||||
|
val items = mutableListOf<NavDrawerItem>()
|
||||||
|
val moreItems = mutableListOf<NavDrawerItem>()
|
||||||
|
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<NavDrawerItem>,
|
||||||
|
val moreItems: List<NavDrawerItem>,
|
||||||
|
val discoverEnabled: Boolean,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val UserDto.tvAccess: Boolean get() = policy?.enableLiveTvAccess == true
|
||||||
|
|
@ -28,21 +28,6 @@ class RefreshRateService
|
||||||
constructor(
|
constructor(
|
||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
) {
|
) {
|
||||||
private val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
|
|
||||||
private val display get() = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
|
|
||||||
|
|
||||||
val supportedDisplayModes get() = display.supportedModes.orEmpty()
|
|
||||||
|
|
||||||
private val displayModes: List<DisplayMode> by lazy {
|
|
||||||
display.supportedModes
|
|
||||||
.orEmpty()
|
|
||||||
.map { DisplayMode(it) }
|
|
||||||
.sortedWith(
|
|
||||||
compareByDescending<DisplayMode>({ it.physicalWidth * it.physicalHeight })
|
|
||||||
.thenBy { it.refreshRateRounded },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the best display mode for the given stream and signal to change to it
|
* Find the best display mode for the given stream and signal to change to it
|
||||||
*/
|
*/
|
||||||
|
|
@ -55,6 +40,18 @@ class RefreshRateService
|
||||||
Timber.v("Not switching either refresh rate nor resolution")
|
Timber.v("Not switching either refresh rate nor resolution")
|
||||||
return@withContext
|
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<DisplayMode>({ it.physicalWidth * it.physicalHeight })
|
||||||
|
.thenBy { it.refreshRateRounded },
|
||||||
|
)
|
||||||
|
|
||||||
val currentDisplayMode = display.mode
|
val currentDisplayMode = display.mode
|
||||||
require(stream.type == MediaStreamType.VIDEO) { "Stream is not video" }
|
require(stream.type == MediaStreamType.VIDEO) { "Stream is not video" }
|
||||||
val width = stream.width
|
val width = stream.width
|
||||||
|
|
|
||||||
|
|
@ -44,18 +44,33 @@ class SeerrServerRepository
|
||||||
private val serverRepository: ServerRepository,
|
private val serverRepository: ServerRepository,
|
||||||
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
|
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
|
||||||
) {
|
) {
|
||||||
private val _current = MutableStateFlow<CurrentSeerr?>(null)
|
private val _connection =
|
||||||
val current: StateFlow<CurrentSeerr?> = _current
|
MutableStateFlow<SeerrConnectionStatus>(SeerrConnectionStatus.NotConfigured)
|
||||||
val currentServer: Flow<SeerrServer?> = current.map { it?.server }
|
val connection: StateFlow<SeerrConnectionStatus> = _connection
|
||||||
val currentUser: Flow<SeerrUser?> = current.map { it?.user }
|
|
||||||
|
val current: Flow<CurrentSeerr?> =
|
||||||
|
_connection.map { (it as? SeerrConnectionStatus.Success)?.current }
|
||||||
|
val currentServer: Flow<SeerrServer?> =
|
||||||
|
connection.map { (it as? SeerrConnectionStatus.Success)?.current?.server }
|
||||||
|
val currentUser: Flow<SeerrUser?> =
|
||||||
|
connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether Seerr integration is currently active of not
|
* Whether Seerr integration is currently active of not
|
||||||
*/
|
*/
|
||||||
val active: Flow<Boolean> = current.map { it != null && seerrApi.active }
|
val active: Flow<Boolean> =
|
||||||
|
connection.map { it is SeerrConnectionStatus.Success && seerrApi.active }
|
||||||
|
|
||||||
fun clear() {
|
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)
|
seerrApi.update("", null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,8 +80,10 @@ class SeerrServerRepository
|
||||||
userConfig: SeerrUserConfig,
|
userConfig: SeerrUserConfig,
|
||||||
) {
|
) {
|
||||||
val publicSettings = seerrApi.api.settingsApi.settingsPublicGet()
|
val publicSettings = seerrApi.api.settingsApi.settingsPublicGet()
|
||||||
_current.update {
|
_connection.update {
|
||||||
CurrentSeerr(server, user, userConfig, publicSettings)
|
SeerrConnectionStatus.Success(
|
||||||
|
CurrentSeerr(server, user, userConfig, publicSettings),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -154,7 +171,7 @@ class SeerrServerRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun removeServer() {
|
suspend fun removeServer() {
|
||||||
val current = _current.value ?: return
|
val current = (_connection.value as? SeerrConnectionStatus.Success)?.current ?: return
|
||||||
seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId)
|
seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId)
|
||||||
clear()
|
clear()
|
||||||
}
|
}
|
||||||
|
|
@ -165,6 +182,19 @@ class SeerrServerRepository
|
||||||
*/
|
*/
|
||||||
typealias SeerrUserConfig = User
|
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(
|
data class CurrentSeerr(
|
||||||
val server: SeerrServer,
|
val server: SeerrServer,
|
||||||
val user: SeerrUser,
|
val user: SeerrUser,
|
||||||
|
|
@ -226,6 +256,7 @@ class UserSwitchListener
|
||||||
private val seerrServerRepository: SeerrServerRepository,
|
private val seerrServerRepository: SeerrServerRepository,
|
||||||
private val seerrServerDao: SeerrServerDao,
|
private val seerrServerDao: SeerrServerDao,
|
||||||
private val seerrApi: SeerrApi,
|
private val seerrApi: SeerrApi,
|
||||||
|
private val homeSettingsService: HomeSettingsService,
|
||||||
) {
|
) {
|
||||||
init {
|
init {
|
||||||
context as AppCompatActivity
|
context as AppCompatActivity
|
||||||
|
|
@ -233,41 +264,47 @@ class UserSwitchListener
|
||||||
serverRepository.currentUser.asFlow().collect { user ->
|
serverRepository.currentUser.asFlow().collect { user ->
|
||||||
Timber.d("New user")
|
Timber.d("New user")
|
||||||
seerrServerRepository.clear()
|
seerrServerRepository.clear()
|
||||||
|
homeSettingsService.currentSettings.update { HomePageResolvedSettings.EMPTY }
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
seerrServerDao
|
// Check for home settings
|
||||||
.getUsersByJellyfinUser(user.rowId)
|
launchIO {
|
||||||
.firstOrNull()
|
homeSettingsService.loadCurrentSettings(user.id)
|
||||||
?.let { seerrUser ->
|
}
|
||||||
val server = seerrServerDao.getServer(seerrUser.serverId)?.server
|
// Check for seerr server
|
||||||
if (server != null) {
|
launchIO {
|
||||||
Timber.i("Found a seerr user & server")
|
seerrServerDao
|
||||||
seerrApi.update(server.url, seerrUser.credential)
|
.getUsersByJellyfinUser(user.rowId)
|
||||||
val userConfig =
|
.lastOrNull()
|
||||||
if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
|
?.let { seerrUser ->
|
||||||
try {
|
val server =
|
||||||
login(
|
seerrServerDao.getServer(seerrUser.serverId)?.server
|
||||||
seerrApi.api,
|
if (server != null) {
|
||||||
seerrUser.authMethod,
|
Timber.i("Found a seerr user & server")
|
||||||
seerrUser.username,
|
try {
|
||||||
seerrUser.password,
|
seerrApi.update(server.url, seerrUser.credential)
|
||||||
)
|
val userConfig =
|
||||||
} catch (ex: Exception) {
|
if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
|
||||||
Timber.w(ex, "Error logging into %s", server.url)
|
login(
|
||||||
seerrServerRepository.clear()
|
seerrApi.api,
|
||||||
return@let
|
seerrUser.authMethod,
|
||||||
}
|
seerrUser.username,
|
||||||
} else {
|
seerrUser.password,
|
||||||
try {
|
)
|
||||||
seerrApi.api.usersApi.authMeGet()
|
} else {
|
||||||
} catch (ex: Exception) {
|
seerrApi.api.usersApi.authMeGet()
|
||||||
Timber.w(ex, "Error logging into %s", server.url)
|
}
|
||||||
seerrServerRepository.clear()
|
seerrServerRepository.set(server, seerrUser, userConfig)
|
||||||
return@let
|
} catch (ex: Exception) {
|
||||||
}
|
Timber.w(
|
||||||
|
ex,
|
||||||
|
"Error logging into %s",
|
||||||
|
server.url,
|
||||||
|
)
|
||||||
|
seerrServerRepository.error(server.url, ex)
|
||||||
}
|
}
|
||||||
seerrServerRepository.set(server, seerrUser, userConfig)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import androidx.work.WorkManager
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
|
@ -49,21 +50,8 @@ class SuggestionService
|
||||||
.asFlow()
|
.asFlow()
|
||||||
.flatMapLatest { user ->
|
.flatMapLatest { user ->
|
||||||
val userId = user?.id ?: return@flatMapLatest flowOf(SuggestionsResource.Empty)
|
val userId = user?.id ?: return@flatMapLatest flowOf(SuggestionsResource.Empty)
|
||||||
val cachedIds = cache.get(userId, parentId, itemKind)?.ids.orEmpty()
|
val cachedSuggestions = cache.get(userId, parentId, itemKind)
|
||||||
if (cachedIds.isNotEmpty()) {
|
if (cachedSuggestions == null) {
|
||||||
flow {
|
|
||||||
try {
|
|
||||||
emit(
|
|
||||||
SuggestionsResource.Success(
|
|
||||||
fetchItemsByIds(cachedIds, itemKind),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.e(e, "Failed to fetch items")
|
|
||||||
emit(SuggestionsResource.Empty)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
workManager
|
workManager
|
||||||
.getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME)
|
.getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME)
|
||||||
.map { workInfos ->
|
.map { workInfos ->
|
||||||
|
|
@ -73,6 +61,23 @@ class SuggestionService
|
||||||
}
|
}
|
||||||
if (isActive) SuggestionsResource.Loading else SuggestionsResource.Empty
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -60,15 +60,20 @@ class SuggestionsCache
|
||||||
): CachedSuggestions? {
|
): CachedSuggestions? {
|
||||||
val key = cacheKey(userId, libraryId, itemKind)
|
val key = cacheKey(userId, libraryId, itemKind)
|
||||||
return memoryCache.getOrPut(key) {
|
return memoryCache.getOrPut(key) {
|
||||||
try {
|
mutex.withLock {
|
||||||
mutex.withLock {
|
try {
|
||||||
File(cacheDir, "$key.json").inputStream().use {
|
val cacheFile = File(cacheDir, "$key.json")
|
||||||
|
if (!cacheFile.exists()) {
|
||||||
|
return@withLock null
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheFile.inputStream().use {
|
||||||
json.decodeFromStream<CachedSuggestions>(it)
|
json.decodeFromStream<CachedSuggestions>(it)
|
||||||
}
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Exception reading from disk cache")
|
||||||
|
null
|
||||||
}
|
}
|
||||||
} catch (ex: Exception) {
|
|
||||||
Timber.e(ex, "Exception reading from disk cache")
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -85,11 +85,8 @@ class SuggestionsWorker
|
||||||
views
|
views
|
||||||
.mapNotNull { view ->
|
.mapNotNull { view ->
|
||||||
val itemKind =
|
val itemKind =
|
||||||
when (view.collectionType) {
|
getTypeForCollection(view.collectionType)
|
||||||
CollectionType.MOVIES -> BaseItemKind.MOVIE
|
?: return@mapNotNull null
|
||||||
CollectionType.TVSHOWS -> BaseItemKind.SERIES
|
|
||||||
else -> return@mapNotNull null
|
|
||||||
}
|
|
||||||
async(Dispatchers.IO) {
|
async(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
Timber.v("Fetching suggestions for view %s", view.id)
|
Timber.v("Fetching suggestions for view %s", view.id)
|
||||||
|
|
@ -267,5 +264,12 @@ class SuggestionsWorker
|
||||||
const val WORK_NAME = "com.github.damontecres.wholphin.services.SuggestionsWorker"
|
const val WORK_NAME = "com.github.damontecres.wholphin.services.SuggestionsWorker"
|
||||||
const val PARAM_USER_ID = "userId"
|
const val PARAM_USER_ID = "userId"
|
||||||
const val PARAM_SERVER_ID = "serverId"
|
const val PARAM_SERVER_ID = "serverId"
|
||||||
|
|
||||||
|
fun getTypeForCollection(collectionType: CollectionType?): BaseItemKind? =
|
||||||
|
when (collectionType) {
|
||||||
|
CollectionType.MOVIES -> BaseItemKind.MOVIE
|
||||||
|
CollectionType.TVSHOWS -> BaseItemKind.SERIES
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,10 @@ annotation class StandardOkHttpClient
|
||||||
@Retention(AnnotationRetention.BINARY)
|
@Retention(AnnotationRetention.BINARY)
|
||||||
annotation class IoCoroutineScope
|
annotation class IoCoroutineScope
|
||||||
|
|
||||||
|
@Qualifier
|
||||||
|
@Retention(AnnotationRetention.BINARY)
|
||||||
|
annotation class DefaultCoroutineScope
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
object AppModule {
|
object AppModule {
|
||||||
|
|
@ -177,6 +181,11 @@ object AppModule {
|
||||||
@IoCoroutineScope
|
@IoCoroutineScope
|
||||||
fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
@DefaultCoroutineScope
|
||||||
|
fun defaultCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun workManager(
|
fun workManager(
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,6 @@ class TvProviderWorker
|
||||||
getPotentialItems(
|
getPotentialItems(
|
||||||
userId,
|
userId,
|
||||||
prefs.homePagePreferences.enableRewatchingNextUp,
|
prefs.homePagePreferences.enableRewatchingNextUp,
|
||||||
prefs.homePagePreferences.combineContinueNext,
|
|
||||||
prefs.homePagePreferences.maxDaysNextUp,
|
prefs.homePagePreferences.maxDaysNextUp,
|
||||||
)
|
)
|
||||||
val potentialItemsToAddIds = potentialItemsToAdd.map { it.id.toString() }
|
val potentialItemsToAddIds = potentialItemsToAdd.map { it.id.toString() }
|
||||||
|
|
@ -145,7 +144,6 @@ class TvProviderWorker
|
||||||
private suspend fun getPotentialItems(
|
private suspend fun getPotentialItems(
|
||||||
userId: UUID,
|
userId: UUID,
|
||||||
enableRewatching: Boolean,
|
enableRewatching: Boolean,
|
||||||
combineContinueNext: Boolean,
|
|
||||||
maxDaysNextUp: Int,
|
maxDaysNextUp: Int,
|
||||||
): List<BaseItem> {
|
): List<BaseItem> {
|
||||||
val resumeItems = latestNextUpService.getResume(userId, 10, true)
|
val resumeItems = latestNextUpService.getResume(userId, 10, true)
|
||||||
|
|
@ -154,11 +152,7 @@ class TvProviderWorker
|
||||||
latestNextUpService
|
latestNextUpService
|
||||||
.getNextUp(userId, 10, enableRewatching, false, maxDaysNextUp)
|
.getNextUp(userId, 10, enableRewatching, false, maxDaysNextUp)
|
||||||
.filter { it.data.seriesId != null && it.data.seriesId !in seriesIds }
|
.filter { it.data.seriesId != null && it.data.seriesId !in seriesIds }
|
||||||
return if (combineContinueNext) {
|
return latestNextUpService.buildCombined(resumeItems, nextUpItems)
|
||||||
latestNextUpService.buildCombined(resumeItems, nextUpItems)
|
|
||||||
} else {
|
|
||||||
resumeItems + nextUpItems
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getCurrentTvChannelNextUp(): List<WatchNextProgram> =
|
private suspend fun getCurrentTvChannelNextUp(): List<WatchNextProgram> =
|
||||||
|
|
|
||||||
|
|
@ -391,6 +391,15 @@ fun CoroutineScope.launchIO(
|
||||||
block: suspend CoroutineScope.() -> Unit,
|
block: suspend CoroutineScope.() -> Unit,
|
||||||
): Job = launch(context = Dispatchers.IO + context, start = start, block = block)
|
): 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).
|
* Converts a UUID to the format used server-side (ie without hyphens).
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import androidx.compose.foundation.text.appendInlineContent
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.WholphinApplication
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.api.MediaSegmentType
|
import org.jellyfin.sdk.model.api.MediaSegmentType
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -76,7 +77,7 @@ val BaseItemDto.seriesProductionYears: String?
|
||||||
append(productionYear.toString())
|
append(productionYear.toString())
|
||||||
if (status == "Continuing") {
|
if (status == "Continuing") {
|
||||||
append(" - ")
|
append(" - ")
|
||||||
append("Present")
|
append(WholphinApplication.instance.getString(R.string.series_continueing))
|
||||||
} else if (status == "Ended") {
|
} else if (status == "Ended") {
|
||||||
endDate?.let {
|
endDate?.let {
|
||||||
if (it.year != productionYear) {
|
if (it.year != productionYear) {
|
||||||
|
|
|
||||||
|
|
@ -82,8 +82,10 @@ val PhotoItemFields =
|
||||||
)
|
)
|
||||||
|
|
||||||
object Cards {
|
object Cards {
|
||||||
val height2x3 = 172.dp
|
const val HEIGHT_2X3_DP = 172
|
||||||
val heightEpisode = height2x3 * .75f
|
val height2x3 = HEIGHT_2X3_DP.dp
|
||||||
|
const val HEIGHT_EPISODE = 128
|
||||||
|
val heightEpisode = HEIGHT_EPISODE.dp
|
||||||
val playedPercentHeight = 6.dp
|
val playedPercentHeight = 6.dp
|
||||||
val serverUserCircle = height2x3 * .75f
|
val serverUserCircle = height2x3 * .75f
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,19 @@
|
||||||
package com.github.damontecres.wholphin.ui.cards
|
package com.github.damontecres.wholphin.ui.cards
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
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.platform.LocalDensity
|
||||||
import androidx.compose.ui.res.colorResource
|
import androidx.compose.ui.res.colorResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.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.Cards
|
||||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||||
|
import com.github.damontecres.wholphin.ui.enableMarquee
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -60,6 +66,9 @@ fun BannerCard(
|
||||||
cardHeight: Dp = 120.dp,
|
cardHeight: Dp = 120.dp,
|
||||||
aspectRatio: Float = AspectRatios.WIDE,
|
aspectRatio: Float = AspectRatios.WIDE,
|
||||||
interactionSource: MutableInteractionSource? = null,
|
interactionSource: MutableInteractionSource? = null,
|
||||||
|
imageType: ImageType = ImageType.PRIMARY,
|
||||||
|
imageContentScale: ContentScale = ContentScale.FillBounds,
|
||||||
|
useSeriesForPrimary: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val imageUrlService = LocalImageUrlService.current
|
val imageUrlService = LocalImageUrlService.current
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
|
@ -74,19 +83,21 @@ fun BannerCard(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val imageUrl =
|
val imageUrl =
|
||||||
remember(item, fillHeight) {
|
remember(item, fillHeight, imageType, useSeriesForPrimary) {
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
imageUrlService.getItemImageUrl(
|
item.imageUrlOverride
|
||||||
item,
|
?: imageUrlService.getItemImageUrl(
|
||||||
ImageType.PRIMARY,
|
item,
|
||||||
fillWidth = null,
|
imageType,
|
||||||
fillHeight = fillHeight,
|
fillWidth = null,
|
||||||
)
|
fillHeight = fillHeight,
|
||||||
|
useSeriesForPrimary = useSeriesForPrimary,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var imageError by remember { mutableStateOf(false) }
|
var imageError by remember(imageUrl) { mutableStateOf(false) }
|
||||||
Card(
|
Card(
|
||||||
modifier = modifier.size(cardHeight * aspectRatio, cardHeight),
|
modifier = modifier.size(cardHeight * aspectRatio, cardHeight),
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
|
@ -107,7 +118,7 @@ fun BannerCard(
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = imageUrl,
|
model = imageUrl,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
contentScale = ContentScale.FillBounds,
|
contentScale = imageContentScale,
|
||||||
onError = { imageError = true },
|
onError = { imageError = true },
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
|
|
@ -115,7 +126,7 @@ fun BannerCard(
|
||||||
Text(
|
Text(
|
||||||
text = name ?: "",
|
text = name ?: "",
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier =
|
modifier =
|
||||||
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -183,6 +184,8 @@ fun DiscoverItemCard(
|
||||||
text = item?.title ?: "",
|
text = item?.title ?: "",
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|
@ -193,6 +196,8 @@ fun DiscoverItemCard(
|
||||||
text = item?.releaseDate?.year?.toString() ?: "",
|
text = item?.releaseDate?.year?.toString() ?: "",
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|
|
||||||
|
|
@ -22,11 +22,13 @@ import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.material3.Card
|
import androidx.tv.material3.Card
|
||||||
import androidx.tv.material3.CardDefaults
|
import androidx.tv.material3.CardDefaults
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.ui.AppColors
|
import com.github.damontecres.wholphin.ui.AppColors
|
||||||
|
|
@ -130,6 +132,8 @@ fun EpisodeCard(
|
||||||
text = dto?.seriesName ?: "",
|
text = dto?.seriesName ?: "",
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|
@ -140,6 +144,8 @@ fun EpisodeCard(
|
||||||
text = item?.name ?: "",
|
text = item?.name ?: "",
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|
|
||||||
|
|
@ -42,11 +42,29 @@ fun GenreCard(
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
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(
|
Card(
|
||||||
modifier =
|
modifier = modifier,
|
||||||
modifier,
|
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
|
|
@ -63,12 +81,12 @@ fun GenreCard(
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.clip(RoundedCornerShape(8.dp)),
|
.clip(RoundedCornerShape(8.dp)),
|
||||||
) {
|
) {
|
||||||
if (genre?.imageUrl.isNotNullOrBlank()) {
|
if (imageUrl != null) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model =
|
model =
|
||||||
ImageRequest
|
ImageRequest
|
||||||
.Builder(LocalContext.current)
|
.Builder(LocalContext.current)
|
||||||
.data(genre.imageUrl)
|
.data(imageUrl)
|
||||||
.crossfade(true)
|
.crossfade(true)
|
||||||
.build(),
|
.build(),
|
||||||
contentScale = ContentScale.FillBounds,
|
contentScale = ContentScale.FillBounds,
|
||||||
|
|
@ -88,7 +106,7 @@ fun GenreCard(
|
||||||
.background(background),
|
.background(background),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = genre?.name ?: "",
|
text = name ?: "",
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
|
|
@ -112,7 +130,6 @@ private fun GenreCardPreview() {
|
||||||
UUID.randomUUID(),
|
UUID.randomUUID(),
|
||||||
"Adventure",
|
"Adventure",
|
||||||
null,
|
null,
|
||||||
Color.Black,
|
|
||||||
)
|
)
|
||||||
GenreCard(
|
GenreCard(
|
||||||
genre = genre,
|
genre = genre,
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
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.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -113,6 +114,8 @@ fun GridCard(
|
||||||
text = item?.title ?: "",
|
text = item?.title ?: "",
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
|
|
@ -124,6 +127,8 @@ fun GridCard(
|
||||||
text = item?.subtitle ?: "",
|
text = item?.subtitle ?: "",
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
|
@ -57,7 +58,7 @@ fun <T> ItemRow(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
modifier = Modifier,
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
)
|
)
|
||||||
LazyRow(
|
LazyRow(
|
||||||
state = state,
|
state = state,
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
@ -174,6 +175,8 @@ fun PersonCard(
|
||||||
text = name ?: "",
|
text = name ?: "",
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|
@ -185,6 +188,8 @@ fun PersonCard(
|
||||||
text = role,
|
text = role,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|
|
||||||
|
|
@ -16,15 +16,16 @@ import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.material3.Card
|
import androidx.tv.material3.Card
|
||||||
import androidx.tv.material3.CardDefaults
|
import androidx.tv.material3.CardDefaults
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||||
|
|
@ -126,21 +127,7 @@ fun SeasonCard(
|
||||||
val focused by interactionSource.collectIsFocusedAsState()
|
val focused by interactionSource.collectIsFocusedAsState()
|
||||||
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
|
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
|
||||||
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
|
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
|
||||||
var focusedAfterDelay by remember { mutableStateOf(false) }
|
val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
|
||||||
|
|
||||||
val hideOverlayDelay = 500L
|
|
||||||
if (focused) {
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
delay(hideOverlayDelay)
|
|
||||||
if (focused) {
|
|
||||||
focusedAfterDelay = true
|
|
||||||
} else {
|
|
||||||
focusedAfterDelay = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
focusedAfterDelay = false
|
|
||||||
}
|
|
||||||
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
|
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
|
||||||
val width = imageHeight * aspectRationToUse
|
val width = imageHeight * aspectRationToUse
|
||||||
val height = imageWidth * (1f / aspectRationToUse)
|
val height = imageWidth * (1f / aspectRationToUse)
|
||||||
|
|
@ -193,6 +180,8 @@ fun SeasonCard(
|
||||||
text = title ?: "",
|
text = title ?: "",
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|
@ -203,6 +192,8 @@ fun SeasonCard(
|
||||||
text = subtitle ?: "",
|
text = subtitle ?: "",
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.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<Boolean> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@ import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.slideInVertically
|
import androidx.compose.animation.slideInVertically
|
||||||
import androidx.compose.animation.slideOutVertically
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
|
@ -33,6 +35,7 @@ import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
|
@ -41,6 +44,7 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
|
|
@ -60,6 +64,8 @@ import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||||
import com.github.damontecres.wholphin.services.MediaReportService
|
import com.github.damontecres.wholphin.services.MediaReportService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
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.ui.AspectRatios
|
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||||
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
|
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
|
||||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||||
|
|
@ -81,6 +87,7 @@ import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||||
import com.github.damontecres.wholphin.ui.toServerString
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
import com.github.damontecres.wholphin.ui.util.FilterUtils
|
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.ApiRequestPager
|
||||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
|
|
@ -120,7 +127,9 @@ class CollectionFolderViewModel
|
||||||
private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
|
private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
|
||||||
private val favoriteWatchManager: FavoriteWatchManager,
|
private val favoriteWatchManager: FavoriteWatchManager,
|
||||||
private val backdropService: BackdropService,
|
private val backdropService: BackdropService,
|
||||||
val navigationManager: NavigationManager,
|
private val navigationManager: NavigationManager,
|
||||||
|
private val themeSongPlayer: ThemeSongPlayer,
|
||||||
|
private val userPreferencesService: UserPreferencesService,
|
||||||
val mediaReportService: MediaReportService,
|
val mediaReportService: MediaReportService,
|
||||||
@Assisted itemId: String,
|
@Assisted itemId: String,
|
||||||
@Assisted initialSortAndDirection: SortAndDirection?,
|
@Assisted initialSortAndDirection: SortAndDirection?,
|
||||||
|
|
@ -157,9 +166,10 @@ class CollectionFolderViewModel
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
super.itemId = itemId
|
super.itemId = itemId
|
||||||
try {
|
try {
|
||||||
itemId.toUUIDOrNull()?.let {
|
val item =
|
||||||
fetchItem(it)
|
itemId.toUUIDOrNull()?.let {
|
||||||
}
|
fetchItem(it)
|
||||||
|
}
|
||||||
|
|
||||||
val libraryDisplayInfo =
|
val libraryDisplayInfo =
|
||||||
serverRepository.currentUser.value?.let { user ->
|
serverRepository.currentUser.value?.let { user ->
|
||||||
|
|
@ -184,6 +194,8 @@ class CollectionFolderViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary)
|
loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary)
|
||||||
|
.join()
|
||||||
|
// onResumePage()
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
Timber.e(ex, "Error during init")
|
Timber.e(ex, "Error during init")
|
||||||
loading.setValueOnMain(DataLoadingState.Error(ex))
|
loading.setValueOnMain(DataLoadingState.Error(ex))
|
||||||
|
|
@ -254,34 +266,32 @@ class CollectionFolderViewModel
|
||||||
recursive: Boolean,
|
recursive: Boolean,
|
||||||
filter: GetItemsFilter,
|
filter: GetItemsFilter,
|
||||||
useSeriesForPrimary: Boolean,
|
useSeriesForPrimary: Boolean,
|
||||||
) {
|
) = viewModelScope.launch(Dispatchers.IO) {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
withContext(Dispatchers.Main) {
|
||||||
withContext(Dispatchers.Main) {
|
if (resetState) {
|
||||||
if (resetState) {
|
loading.value = DataLoadingState.Loading
|
||||||
loading.value = DataLoadingState.Loading
|
|
||||||
}
|
|
||||||
backgroundLoading.value = LoadingState.Loading
|
|
||||||
this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection
|
|
||||||
this@CollectionFolderViewModel.filter.value = filter
|
|
||||||
}
|
}
|
||||||
try {
|
backgroundLoading.value = LoadingState.Loading
|
||||||
val newPager =
|
this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection
|
||||||
createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init()
|
this@CollectionFolderViewModel.filter.value = filter
|
||||||
if (newPager.isNotEmpty()) newPager.getBlocking(0)
|
}
|
||||||
withContext(Dispatchers.Main) {
|
try {
|
||||||
loading.value = DataLoadingState.Success(newPager)
|
val newPager =
|
||||||
backgroundLoading.value = LoadingState.Success
|
createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init()
|
||||||
}
|
if (newPager.isNotEmpty()) newPager.getBlocking(0)
|
||||||
} catch (ex: Exception) {
|
withContext(Dispatchers.Main) {
|
||||||
Timber.e(
|
loading.value = DataLoadingState.Success(newPager)
|
||||||
ex,
|
backgroundLoading.value = LoadingState.Success
|
||||||
"Exception while loading data: sort=%s, filter=%s",
|
}
|
||||||
sortAndDirection,
|
} catch (ex: Exception) {
|
||||||
filter,
|
Timber.e(
|
||||||
)
|
ex,
|
||||||
withContext(Dispatchers.Main) {
|
"Exception while loading data: sort=%s, filter=%s",
|
||||||
loading.value = DataLoadingState.Error(ex)
|
sortAndDirection,
|
||||||
}
|
filter,
|
||||||
|
)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
loading.value = DataLoadingState.Error(ex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -346,7 +356,12 @@ class CollectionFolderViewModel
|
||||||
filter.applyTo(
|
filter.applyTo(
|
||||||
GetItemsRequest(
|
GetItemsRequest(
|
||||||
parentId = item?.id,
|
parentId = item?.id,
|
||||||
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
|
enableImageTypes =
|
||||||
|
listOf(
|
||||||
|
ImageType.PRIMARY,
|
||||||
|
ImageType.THUMB,
|
||||||
|
ImageType.BACKDROP,
|
||||||
|
),
|
||||||
includeItemTypes = includeItemTypes,
|
includeItemTypes = includeItemTypes,
|
||||||
recursive = recursive,
|
recursive = recursive,
|
||||||
excludeItemIds = item?.let { listOf(item.id) },
|
excludeItemIds = item?.let { listOf(item.id) },
|
||||||
|
|
@ -438,6 +453,30 @@ class CollectionFolderViewModel
|
||||||
backdropService.submit(item)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -531,7 +570,7 @@ fun CollectionFolderGrid(
|
||||||
DataLoadingState.Loading,
|
DataLoadingState.Loading,
|
||||||
DataLoadingState.Pending,
|
DataLoadingState.Pending,
|
||||||
-> {
|
-> {
|
||||||
LoadingPage()
|
LoadingPage(modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
is DataLoadingState.Error,
|
is DataLoadingState.Error,
|
||||||
|
|
@ -543,6 +582,13 @@ fun CollectionFolderGrid(
|
||||||
?: item?.data?.collectionType?.name
|
?: item?.data?.collectionType?.name
|
||||||
?: stringResource(R.string.collection)
|
?: stringResource(R.string.collection)
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
|
LifecycleResumeEffect(itemId) {
|
||||||
|
viewModel.onResumePage()
|
||||||
|
|
||||||
|
onPauseOrDispose {
|
||||||
|
viewModel.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
CollectionFolderGridContent(
|
CollectionFolderGridContent(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
initialPosition = viewModel.position,
|
initialPosition = viewModel.position,
|
||||||
|
|
@ -593,7 +639,7 @@ fun CollectionFolderGrid(
|
||||||
} else {
|
} else {
|
||||||
Destination.Playback(item)
|
Destination.Playback(item)
|
||||||
}
|
}
|
||||||
viewModel.navigationManager.navigateTo(destination)
|
viewModel.navigateTo(destination)
|
||||||
},
|
},
|
||||||
onClickPlayAll = { shuffle ->
|
onClickPlayAll = { shuffle ->
|
||||||
itemId.toUUIDOrNull()?.let {
|
itemId.toUUIDOrNull()?.let {
|
||||||
|
|
@ -617,7 +663,7 @@ fun CollectionFolderGrid(
|
||||||
filter = filter,
|
filter = filter,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
viewModel.navigationManager.navigateTo(destination)
|
viewModel.navigateTo(destination)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -655,7 +701,7 @@ fun CollectionFolderGrid(
|
||||||
favorite = item.favorite,
|
favorite = item.favorite,
|
||||||
actions =
|
actions =
|
||||||
MoreDialogActions(
|
MoreDialogActions(
|
||||||
navigateTo = { viewModel.navigationManager.navigateTo(it) },
|
navigateTo = { viewModel.navigateTo(it) },
|
||||||
onClickWatch = { itemId, watched ->
|
onClickWatch = { itemId, watched ->
|
||||||
viewModel.setWatched(position, itemId, watched)
|
viewModel.setWatched(position, itemId, watched)
|
||||||
},
|
},
|
||||||
|
|
@ -693,6 +739,7 @@ fun CollectionFolderGrid(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun CollectionFolderGridContent(
|
fun CollectionFolderGridContent(
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
|
|
@ -837,14 +884,16 @@ fun CollectionFolderGridContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current
|
||||||
|
val density = LocalDensity.current
|
||||||
AnimatedVisibility(viewOptions.showDetails) {
|
AnimatedVisibility(viewOptions.showDetails) {
|
||||||
HomePageHeader(
|
HomePageHeader(
|
||||||
item = focusedItem,
|
item = focusedItem,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(140.dp)
|
.height(200.dp)
|
||||||
.padding(16.dp),
|
.padding(top = 48.dp, bottom = 32.dp, start = 8.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
when (val state = loadingState) {
|
when (val state = loadingState) {
|
||||||
|
|
@ -852,7 +901,7 @@ fun CollectionFolderGridContent(
|
||||||
DataLoadingState.Loading,
|
DataLoadingState.Loading,
|
||||||
-> {
|
-> {
|
||||||
// This shouldn't happen, so just show placeholder
|
// This shouldn't happen, so just show placeholder
|
||||||
Text("Loading")
|
Text(stringResource(R.string.loading))
|
||||||
}
|
}
|
||||||
|
|
||||||
is DataLoadingState.Error -> {
|
is DataLoadingState.Error -> {
|
||||||
|
|
@ -890,6 +939,15 @@ fun CollectionFolderGridContent(
|
||||||
},
|
},
|
||||||
columns = viewOptions.columns,
|
columns = viewOptions.columns,
|
||||||
spacing = viewOptions.spacing.dp,
|
spacing = viewOptions.spacing.dp,
|
||||||
|
bringIntoViewSpec =
|
||||||
|
remember(viewOptions) {
|
||||||
|
val spacingPx = with(density) { viewOptions.spacing.dp.toPx() }
|
||||||
|
if (viewOptions.showDetails) {
|
||||||
|
ScrollToTopBringIntoViewSpec(spacingPx)
|
||||||
|
} else {
|
||||||
|
defaultBringIntoViewSpec
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
AnimatedVisibility(showViewOptions) {
|
AnimatedVisibility(showViewOptions) {
|
||||||
ViewOptionsDialog(
|
ViewOptionsDialog(
|
||||||
|
|
|
||||||
|
|
@ -408,12 +408,13 @@ fun ConfirmDialog(
|
||||||
onConfirm: () -> Unit,
|
onConfirm: () -> Unit,
|
||||||
properties: DialogProperties = DialogProperties(),
|
properties: DialogProperties = DialogProperties(),
|
||||||
elevation: Dp = 8.dp,
|
elevation: Dp = 8.dp,
|
||||||
|
bodyColor: Color = MaterialTheme.colorScheme.onSurface,
|
||||||
) = BasicDialog(
|
) = BasicDialog(
|
||||||
onDismissRequest = onCancel,
|
onDismissRequest = onCancel,
|
||||||
properties = properties,
|
properties = properties,
|
||||||
elevation = elevation,
|
elevation = elevation,
|
||||||
content = {
|
content = {
|
||||||
ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier)
|
ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier, bodyColor)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -427,6 +428,7 @@ fun ConfirmDialogContent(
|
||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
onConfirm: () -> Unit,
|
onConfirm: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
bodyColor: Color = MaterialTheme.colorScheme.onSurface,
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
|
@ -446,7 +448,7 @@ fun ConfirmDialogContent(
|
||||||
item {
|
item {
|
||||||
Text(
|
Text(
|
||||||
text = body,
|
text = body,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = bodyColor,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,12 +5,12 @@ import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -21,8 +21,7 @@ import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
import com.github.damontecres.wholphin.data.model.createGenreDestination
|
||||||
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
|
||||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
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.cards.GenreCard
|
||||||
import com.github.damontecres.wholphin.ui.detail.CardGrid
|
import com.github.damontecres.wholphin.ui.detail.CardGrid
|
||||||
import com.github.damontecres.wholphin.ui.detail.CardGridItem
|
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.setValueOnMain
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
|
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
|
import com.mayakapps.kache.InMemoryKache
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
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.ItemSortBy
|
||||||
import org.jellyfin.sdk.model.api.request.GetGenresRequest
|
import org.jellyfin.sdk.model.api.request.GetGenresRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
|
||||||
@HiltViewModel(assistedFactory = GenreViewModel.Factory::class)
|
@HiltViewModel(assistedFactory = GenreViewModel.Factory::class)
|
||||||
class GenreViewModel
|
class GenreViewModel
|
||||||
|
|
@ -102,51 +104,23 @@ class GenreViewModel
|
||||||
.execute(api, request)
|
.execute(api, request)
|
||||||
.content.items
|
.content.items
|
||||||
.map {
|
.map {
|
||||||
Genre(it.id, it.name ?: "", null, Color.Black)
|
Genre(it.id, it.name ?: "", null)
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
this@GenreViewModel.genres.value = genres
|
this@GenreViewModel.genres.value = genres
|
||||||
loading.value = LoadingState.Success
|
loading.value = LoadingState.Success
|
||||||
}
|
}
|
||||||
val genreToUrl = ConcurrentHashMap<UUID, String?>()
|
val genreToUrl =
|
||||||
val semaphore = Semaphore(4)
|
getGenreImageMap(
|
||||||
genres
|
api = api,
|
||||||
.map { genre ->
|
userId = serverRepository.currentUser.value?.id,
|
||||||
viewModelScope.async(Dispatchers.IO) {
|
scope = viewModelScope,
|
||||||
semaphore.withPermit {
|
imageUrlService = imageUrlService,
|
||||||
val item =
|
genres = genres.map { it.id },
|
||||||
GetItemsRequestHandler
|
parentId = itemId,
|
||||||
.execute(
|
includeItemTypes = includeItemTypes,
|
||||||
api,
|
cardWidthPx = cardWidthPx,
|
||||||
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 genresWithImages =
|
val genresWithImages =
|
||||||
genres.map {
|
genres.map {
|
||||||
it.copy(
|
it.copy(
|
||||||
|
|
@ -171,11 +145,84 @@ class GenreViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class GenreCacheKey(
|
||||||
|
val userId: UUID?,
|
||||||
|
val parentId: UUID,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val genreCache by lazy {
|
||||||
|
InMemoryKache<GenreCacheKey, Map<UUID, String?>>(8) {
|
||||||
|
expireAfterWriteDuration = 2.hours
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getGenreImageMap(
|
||||||
|
api: ApiClient,
|
||||||
|
userId: UUID?,
|
||||||
|
scope: CoroutineScope,
|
||||||
|
imageUrlService: ImageUrlService,
|
||||||
|
genres: List<UUID>,
|
||||||
|
parentId: UUID,
|
||||||
|
includeItemTypes: List<BaseItemKind>?,
|
||||||
|
cardWidthPx: Int?,
|
||||||
|
useCache: Boolean = true,
|
||||||
|
): Map<UUID, String?> {
|
||||||
|
val key = GenreCacheKey(userId, parentId)
|
||||||
|
if (useCache) {
|
||||||
|
genreCache.getIfAvailable(key)?.let {
|
||||||
|
Timber.v("Got cached entry")
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val genreToUrl = ConcurrentHashMap<UUID, String?>()
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.awaitAll()
|
||||||
|
genreCache.put(key, genreToUrl)
|
||||||
|
return genreToUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
@Stable
|
||||||
data class Genre(
|
data class Genre(
|
||||||
val id: UUID,
|
val id: UUID,
|
||||||
val name: String,
|
val name: String,
|
||||||
val imageUrl: String?,
|
val imageUrl: String?,
|
||||||
val color: Color,
|
|
||||||
) : CardGridItem {
|
) : CardGridItem {
|
||||||
override val gridId: String get() = id.toString()
|
override val gridId: String get() = id.toString()
|
||||||
override val playable: Boolean = false
|
override val playable: Boolean = false
|
||||||
|
|
@ -233,23 +280,12 @@ fun GenreCardGrid(
|
||||||
pager = genres,
|
pager = genres,
|
||||||
onClickItem = { _, genre ->
|
onClickItem = { _, genre ->
|
||||||
viewModel.navigationManager.navigateTo(
|
viewModel.navigationManager.navigateTo(
|
||||||
Destination.FilteredCollection(
|
createGenreDestination(
|
||||||
itemId = itemId,
|
genreId = genre.id,
|
||||||
filter =
|
genreName = genre.name,
|
||||||
CollectionFolderFilter(
|
parentId = itemId,
|
||||||
nameOverride =
|
parentName = item?.title,
|
||||||
listOfNotNull(
|
includeItemTypes = includeItemTypes,
|
||||||
genre.name,
|
|
||||||
item?.title,
|
|
||||||
).joinToString(" "),
|
|
||||||
filter =
|
|
||||||
GetItemsFilter(
|
|
||||||
genres = listOf(genre.id),
|
|
||||||
includeItemTypes = includeItemTypes,
|
|
||||||
),
|
|
||||||
useSavedLibraryDisplayInfo = false,
|
|
||||||
),
|
|
||||||
recursive = true,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -95,13 +95,13 @@ fun ItemGrid(
|
||||||
val items by viewModel.items.observeAsState(listOf())
|
val items by viewModel.items.observeAsState(listOf())
|
||||||
when (val state = loading) {
|
when (val state = loading) {
|
||||||
is LoadingState.Error -> {
|
is LoadingState.Error -> {
|
||||||
ErrorMessage(state)
|
ErrorMessage(state, modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Loading,
|
LoadingState.Loading,
|
||||||
LoadingState.Pending,
|
LoadingState.Pending,
|
||||||
-> {
|
-> {
|
||||||
LoadingPage()
|
LoadingPage(modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Success -> {
|
LoadingState.Success -> {
|
||||||
|
|
|
||||||
|
|
@ -34,13 +34,14 @@ import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.main.HomePageContent
|
import com.github.damontecres.wholphin.ui.main.HomePageContent
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
import com.github.damontecres.wholphin.ui.rememberPosition
|
||||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
|
import kotlinx.coroutines.Deferred
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.jellyfin.sdk.model.api.MediaType
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
|
@ -100,13 +101,13 @@ abstract class RecommendedViewModel(
|
||||||
abstract fun update(
|
abstract fun update(
|
||||||
@StringRes title: Int,
|
@StringRes title: Int,
|
||||||
row: HomeRowLoadingState,
|
row: HomeRowLoadingState,
|
||||||
)
|
): HomeRowLoadingState
|
||||||
|
|
||||||
fun update(
|
fun update(
|
||||||
@StringRes title: Int,
|
@StringRes title: Int,
|
||||||
block: suspend () -> List<BaseItem>,
|
block: suspend () -> List<BaseItem>,
|
||||||
) {
|
): Deferred<HomeRowLoadingState> =
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.async(Dispatchers.IO) {
|
||||||
val titleStr = context.getString(title)
|
val titleStr = context.getString(title)
|
||||||
val row =
|
val row =
|
||||||
try {
|
try {
|
||||||
|
|
@ -115,10 +116,7 @@ abstract class RecommendedViewModel(
|
||||||
HomeRowLoadingState.Error(titleStr, null, ex)
|
HomeRowLoadingState.Error(titleStr, null, ex)
|
||||||
}
|
}
|
||||||
update(title, row)
|
update(title, row)
|
||||||
// TODO
|
|
||||||
loading.setValueOnMain(LoadingState.Success)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -142,18 +140,20 @@ fun RecommendedContent(
|
||||||
|
|
||||||
when (val state = loading) {
|
when (val state = loading) {
|
||||||
is LoadingState.Error -> {
|
is LoadingState.Error -> {
|
||||||
ErrorMessage(state)
|
ErrorMessage(state, modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Loading,
|
LoadingState.Loading,
|
||||||
LoadingState.Pending,
|
LoadingState.Pending,
|
||||||
-> {
|
-> {
|
||||||
LoadingPage()
|
LoadingPage(modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Success -> {
|
LoadingState.Success -> {
|
||||||
|
var position by rememberPosition()
|
||||||
HomePageContent(
|
HomePageContent(
|
||||||
homeRows = rows,
|
homeRows = rows,
|
||||||
|
position = position,
|
||||||
onClickItem = { _, item ->
|
onClickItem = { _, item ->
|
||||||
viewModel.navigationManager.navigateTo(item.destination())
|
viewModel.navigationManager.navigateTo(item.destination())
|
||||||
},
|
},
|
||||||
|
|
@ -163,7 +163,21 @@ fun RecommendedContent(
|
||||||
onClickPlay = { _, item ->
|
onClickPlay = { _, item ->
|
||||||
viewModel.navigationManager.navigateTo(Destination.Playback(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,
|
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
||||||
onUpdateBackdrop = viewModel::updateBackdrop,
|
onUpdateBackdrop = viewModel::updateBackdrop,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Deferred
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
|
@ -123,6 +125,8 @@ class RecommendedMovieViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val jobs = mutableListOf<Deferred<HomeRowLoadingState>>()
|
||||||
|
|
||||||
update(R.string.recently_released) {
|
update(R.string.recently_released) {
|
||||||
val request =
|
val request =
|
||||||
GetItemsRequest(
|
GetItemsRequest(
|
||||||
|
|
@ -138,7 +142,7 @@ class RecommendedMovieViewModel
|
||||||
enableTotalRecordCount = false,
|
enableTotalRecordCount = false,
|
||||||
)
|
)
|
||||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
|
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
|
||||||
}
|
}.also(jobs::add)
|
||||||
|
|
||||||
update(R.string.recently_added) {
|
update(R.string.recently_added) {
|
||||||
val request =
|
val request =
|
||||||
|
|
@ -155,7 +159,7 @@ class RecommendedMovieViewModel
|
||||||
enableTotalRecordCount = false,
|
enableTotalRecordCount = false,
|
||||||
)
|
)
|
||||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
|
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
|
||||||
}
|
}.also(jobs::add)
|
||||||
|
|
||||||
update(R.string.top_unwatched) {
|
update(R.string.top_unwatched) {
|
||||||
val request =
|
val request =
|
||||||
|
|
@ -173,7 +177,7 @@ class RecommendedMovieViewModel
|
||||||
enableTotalRecordCount = false,
|
enableTotalRecordCount = false,
|
||||||
)
|
)
|
||||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
|
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
|
||||||
}
|
}.also(jobs::add)
|
||||||
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -204,6 +208,8 @@ class RecommendedMovieViewModel
|
||||||
}
|
}
|
||||||
update(R.string.suggestions, state)
|
update(R.string.suggestions, state)
|
||||||
}
|
}
|
||||||
|
} catch (ex: CancellationException) {
|
||||||
|
throw ex
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
Timber.e(ex, "Failed to fetch suggestions")
|
Timber.e(ex, "Failed to fetch suggestions")
|
||||||
update(
|
update(
|
||||||
|
|
@ -216,8 +222,17 @@ class RecommendedMovieViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) {
|
||||||
loading.setValueOnMain(LoadingState.Success)
|
for (i in 0..<jobs.size) {
|
||||||
|
val result = jobs[i].await()
|
||||||
|
if (result.completed) {
|
||||||
|
Timber.v("First success")
|
||||||
|
loading.setValueOnMain(LoadingState.Success)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -225,10 +240,11 @@ class RecommendedMovieViewModel
|
||||||
override fun update(
|
override fun update(
|
||||||
@StringRes title: Int,
|
@StringRes title: Int,
|
||||||
row: HomeRowLoadingState,
|
row: HomeRowLoadingState,
|
||||||
) {
|
): HomeRowLoadingState {
|
||||||
rows.update { current ->
|
rows.update { current ->
|
||||||
current.toMutableList().apply { set(rowTitles[title]!!, row) }
|
current.toMutableList().apply { set(rowTitles[title]!!, row) }
|
||||||
}
|
}
|
||||||
|
return row
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Deferred
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
@ -170,6 +172,8 @@ class RecommendedTvShowViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val jobs = mutableListOf<Deferred<HomeRowLoadingState>>()
|
||||||
|
|
||||||
update(R.string.recently_released) {
|
update(R.string.recently_released) {
|
||||||
val request =
|
val request =
|
||||||
GetItemsRequest(
|
GetItemsRequest(
|
||||||
|
|
@ -185,7 +189,7 @@ class RecommendedTvShowViewModel
|
||||||
enableTotalRecordCount = false,
|
enableTotalRecordCount = false,
|
||||||
)
|
)
|
||||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, true)
|
GetItemsRequestHandler.execute(api, request).toBaseItems(api, true)
|
||||||
}
|
}.also(jobs::add)
|
||||||
|
|
||||||
update(R.string.recently_added) {
|
update(R.string.recently_added) {
|
||||||
val request =
|
val request =
|
||||||
|
|
@ -202,7 +206,7 @@ class RecommendedTvShowViewModel
|
||||||
enableTotalRecordCount = false,
|
enableTotalRecordCount = false,
|
||||||
)
|
)
|
||||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, true)
|
GetItemsRequestHandler.execute(api, request).toBaseItems(api, true)
|
||||||
}
|
}.also(jobs::add)
|
||||||
|
|
||||||
update(R.string.top_unwatched) {
|
update(R.string.top_unwatched) {
|
||||||
val request =
|
val request =
|
||||||
|
|
@ -220,7 +224,7 @@ class RecommendedTvShowViewModel
|
||||||
enableTotalRecordCount = false,
|
enableTotalRecordCount = false,
|
||||||
)
|
)
|
||||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, true)
|
GetItemsRequestHandler.execute(api, request).toBaseItems(api, true)
|
||||||
}
|
}.also(jobs::add)
|
||||||
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -251,6 +255,8 @@ class RecommendedTvShowViewModel
|
||||||
}
|
}
|
||||||
update(R.string.suggestions, state)
|
update(R.string.suggestions, state)
|
||||||
}
|
}
|
||||||
|
} catch (ex: CancellationException) {
|
||||||
|
throw ex
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
Timber.e(ex, "Failed to fetch suggestions")
|
Timber.e(ex, "Failed to fetch suggestions")
|
||||||
update(
|
update(
|
||||||
|
|
@ -264,7 +270,14 @@ class RecommendedTvShowViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) {
|
if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) {
|
||||||
loading.setValueOnMain(LoadingState.Success)
|
for (i in 0..<jobs.size) {
|
||||||
|
val result = jobs[i].await()
|
||||||
|
if (result is HomeRowLoadingState.Success) {
|
||||||
|
Timber.v("First success")
|
||||||
|
loading.setValueOnMain(LoadingState.Success)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -272,10 +285,11 @@ class RecommendedTvShowViewModel
|
||||||
override fun update(
|
override fun update(
|
||||||
@StringRes title: Int,
|
@StringRes title: Int,
|
||||||
row: HomeRowLoadingState,
|
row: HomeRowLoadingState,
|
||||||
) {
|
): HomeRowLoadingState {
|
||||||
rows.update { current ->
|
rows.update { current ->
|
||||||
current.toMutableList().apply { set(rowTitles[title]!!, row) }
|
current.toMutableList().apply { set(rowTitles[title]!!, row) }
|
||||||
}
|
}
|
||||||
|
return row
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import androidx.annotation.StringRes
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.focusGroup
|
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.MutableInteractionSource
|
||||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
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.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
|
@ -112,6 +115,7 @@ fun <T : CardGridItem> CardGrid(
|
||||||
},
|
},
|
||||||
columns: Int = 6,
|
columns: Int = 6,
|
||||||
spacing: Dp = 16.dp,
|
spacing: Dp = 16.dp,
|
||||||
|
bringIntoViewSpec: BringIntoViewSpec = LocalBringIntoViewSpec.current,
|
||||||
) {
|
) {
|
||||||
val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0))
|
val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0))
|
||||||
|
|
||||||
|
|
@ -269,100 +273,102 @@ fun <T : CardGridItem> CardGrid(
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
) {
|
) {
|
||||||
LazyVerticalGrid(
|
CompositionLocalProvider(LocalBringIntoViewSpec provides bringIntoViewSpec) {
|
||||||
columns = GridCells.Fixed(columns),
|
LazyVerticalGrid(
|
||||||
horizontalArrangement = Arrangement.spacedBy(spacing),
|
columns = GridCells.Fixed(columns),
|
||||||
verticalArrangement = Arrangement.spacedBy(spacing),
|
horizontalArrangement = Arrangement.spacedBy(spacing),
|
||||||
state = gridState,
|
verticalArrangement = Arrangement.spacedBy(spacing),
|
||||||
contentPadding = PaddingValues(vertical = 16.dp),
|
state = gridState,
|
||||||
modifier =
|
contentPadding = PaddingValues(vertical = 16.dp),
|
||||||
Modifier
|
modifier =
|
||||||
.fillMaxSize()
|
Modifier
|
||||||
.focusGroup()
|
.fillMaxSize()
|
||||||
.focusRestorer(firstFocus)
|
.focusGroup()
|
||||||
.focusProperties {
|
.focusRestorer(firstFocus)
|
||||||
onExit = {
|
.focusProperties {
|
||||||
// Leaving the grid, so "forget" the position
|
onExit = {
|
||||||
|
// Leaving the grid, so "forget" the position
|
||||||
// focusedIndex = -1
|
// focusedIndex = -1
|
||||||
}
|
|
||||||
onEnter = {
|
|
||||||
if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) {
|
|
||||||
focusedIndex = startPosition
|
|
||||||
}
|
}
|
||||||
}
|
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}",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (focusState.isFocused) {
|
},
|
||||||
// Focused, so set that up
|
) {
|
||||||
focusOn(index)
|
items(pager.size) { index ->
|
||||||
positionCallback?.invoke(columns, index)
|
val mod =
|
||||||
} else if (focusedIndex == index) {
|
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
|
// savedFocusedIndex = index
|
||||||
// // Was focused on this, so mark unfocused
|
// // Was focused on this, so mark unfocused
|
||||||
// focusedIndex = -1
|
// focusedIndex = -1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
if (pager.isEmpty()) {
|
||||||
if (pager.isEmpty()) {
|
|
||||||
// focusedIndex = -1
|
// focusedIndex = -1
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.no_results),
|
text = stringResource(R.string.no_results),
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
modifier = Modifier.align(Alignment.Center),
|
modifier = Modifier.align(Alignment.Center),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
if (showFooter) {
|
||||||
if (showFooter) {
|
// Footer
|
||||||
// Footer
|
Box(
|
||||||
Box(
|
modifier =
|
||||||
modifier =
|
Modifier
|
||||||
Modifier
|
.align(Alignment.BottomCenter)
|
||||||
.align(Alignment.BottomCenter)
|
.background(AppColors.TransparentBlack50),
|
||||||
.background(AppColors.TransparentBlack50),
|
) {
|
||||||
) {
|
val index = (focusedIndex + 1).takeIf { it > 0 } ?: "?"
|
||||||
val index = (focusedIndex + 1).takeIf { it > 0 } ?: "?"
|
|
||||||
// if (focusedIndex >= 0) {
|
// if (focusedIndex >= 0) {
|
||||||
// focusedIndex + 1
|
// focusedIndex + 1
|
||||||
// } else {
|
// } else {
|
||||||
// max(savedFocusedIndex, focusedIndexOnExit) + 1
|
// max(savedFocusedIndex, focusedIndexOnExit) + 1
|
||||||
// }
|
// }
|
||||||
Text(
|
Text(
|
||||||
modifier = Modifier.padding(4.dp),
|
modifier = Modifier.padding(4.dp),
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
text = "$index / ${pager.size}",
|
text = "$index / ${pager.size}",
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ fun CollectionFolderPhotoAlbum(
|
||||||
} else {
|
} else {
|
||||||
item.destination(index)
|
item.destination(index)
|
||||||
}
|
}
|
||||||
viewModel.navigationManager.navigateTo(destination)
|
viewModel.navigateTo(destination)
|
||||||
},
|
},
|
||||||
itemId = itemId.toServerString(),
|
itemId = itemId.toServerString(),
|
||||||
initialFilter = filter,
|
initialFilter = filter,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
package com.github.damontecres.wholphin.ui.detail
|
package com.github.damontecres.wholphin.ui.detail
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.hardware.display.DisplayManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import android.view.Display
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.focusable
|
import androidx.compose.foundation.focusable
|
||||||
import androidx.compose.foundation.gestures.scrollBy
|
import androidx.compose.foundation.gestures.scrollBy
|
||||||
|
|
@ -32,11 +34,11 @@ import androidx.lifecycle.viewModelScope
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.BuildConfig
|
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.ItemPlaybackDao
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
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.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.showToast
|
import com.github.damontecres.wholphin.ui.showToast
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
|
|
@ -60,13 +62,19 @@ class DebugViewModel
|
||||||
constructor(
|
constructor(
|
||||||
val serverRepository: ServerRepository,
|
val serverRepository: ServerRepository,
|
||||||
val itemPlaybackDao: ItemPlaybackDao,
|
val itemPlaybackDao: ItemPlaybackDao,
|
||||||
val refreshRateService: RefreshRateService,
|
|
||||||
val clientInfo: ClientInfo,
|
val clientInfo: ClientInfo,
|
||||||
val deviceInfo: DeviceInfo,
|
val deviceInfo: DeviceInfo,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val itemPlaybacks = MutableLiveData<List<ItemPlayback>>(listOf())
|
val itemPlaybacks = MutableLiveData<List<ItemPlayback>>(listOf())
|
||||||
val logcat = MutableLiveData<List<LogcatLine>>(listOf())
|
val logcat = MutableLiveData<List<LogcatLine>>(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 {
|
init {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
serverRepository.currentUser.value?.rowId?.let {
|
serverRepository.currentUser.value?.rowId?.let {
|
||||||
|
|
@ -260,7 +268,7 @@ fun DebugPage(
|
||||||
"Model: ${Build.MODEL}",
|
"Model: ${Build.MODEL}",
|
||||||
"API Level: ${Build.VERSION.SDK_INT}",
|
"API Level: ${Build.VERSION.SDK_INT}",
|
||||||
"Display Modes:",
|
"Display Modes:",
|
||||||
*viewModel.refreshRateService.supportedDisplayModes,
|
*viewModel.supportedModes,
|
||||||
).forEach {
|
).forEach {
|
||||||
Text(
|
Text(
|
||||||
text = it.toString(),
|
text = it.toString(),
|
||||||
|
|
|
||||||
|
|
@ -113,13 +113,13 @@ fun DiscoverMovieDetails(
|
||||||
|
|
||||||
when (val state = loading) {
|
when (val state = loading) {
|
||||||
is LoadingState.Error -> {
|
is LoadingState.Error -> {
|
||||||
ErrorMessage(state)
|
ErrorMessage(state, modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Loading,
|
LoadingState.Loading,
|
||||||
LoadingState.Pending,
|
LoadingState.Pending,
|
||||||
-> {
|
-> {
|
||||||
LoadingPage()
|
LoadingPage(modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Success -> {
|
LoadingState.Success -> {
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,8 @@ class DiscoverMovieViewModel
|
||||||
val canCancelRequest = MutableStateFlow(false)
|
val canCancelRequest = MutableStateFlow(false)
|
||||||
|
|
||||||
val userConfig = seerrServerRepository.current.map { it?.config }
|
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 {
|
||||||
init()
|
init()
|
||||||
|
|
|
||||||
|
|
@ -105,13 +105,13 @@ fun DiscoverSeriesDetails(
|
||||||
|
|
||||||
when (val state = loading) {
|
when (val state = loading) {
|
||||||
is LoadingState.Error -> {
|
is LoadingState.Error -> {
|
||||||
ErrorMessage(state)
|
ErrorMessage(state, modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Loading,
|
LoadingState.Loading,
|
||||||
LoadingState.Pending,
|
LoadingState.Pending,
|
||||||
-> {
|
-> {
|
||||||
LoadingPage()
|
LoadingPage(modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Success -> {
|
LoadingState.Success -> {
|
||||||
|
|
|
||||||
|
|
@ -102,13 +102,13 @@ fun EpisodeDetails(
|
||||||
|
|
||||||
when (val state = loading) {
|
when (val state = loading) {
|
||||||
is LoadingState.Error -> {
|
is LoadingState.Error -> {
|
||||||
ErrorMessage(state)
|
ErrorMessage(state, modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Loading,
|
LoadingState.Loading,
|
||||||
LoadingState.Pending,
|
LoadingState.Pending,
|
||||||
-> {
|
-> {
|
||||||
LoadingPage()
|
LoadingPage(modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Success -> {
|
LoadingState.Success -> {
|
||||||
|
|
|
||||||
|
|
@ -130,13 +130,13 @@ fun MovieDetails(
|
||||||
|
|
||||||
when (val state = loading) {
|
when (val state = loading) {
|
||||||
is LoadingState.Error -> {
|
is LoadingState.Error -> {
|
||||||
ErrorMessage(state)
|
ErrorMessage(state, modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Loading,
|
LoadingState.Loading,
|
||||||
LoadingState.Pending,
|
LoadingState.Pending,
|
||||||
-> {
|
-> {
|
||||||
LoadingPage()
|
LoadingPage(modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Success -> {
|
LoadingState.Success -> {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,248 @@
|
||||||
|
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.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<String?>(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
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 {
|
||||||
|
val titleRes =
|
||||||
|
remember {
|
||||||
|
when (searchType) {
|
||||||
|
BaseItemKind.BOX_SET -> R.string.collections
|
||||||
|
BaseItemKind.PLAYLIST -> R.string.playlists
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ItemRow(
|
||||||
|
title = titleRes?.let { stringResource(it) } ?: "",
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.detail.series
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusState
|
import androidx.compose.ui.focus.FocusState
|
||||||
|
|
@ -31,17 +32,17 @@ fun FocusedEpisodeHeader(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
EpisodeName(dto, modifier = Modifier)
|
EpisodeName(dto, modifier = Modifier.padding(start = 8.dp))
|
||||||
|
|
||||||
ep?.ui?.quickDetails?.let {
|
ep?.ui?.quickDetails?.let {
|
||||||
QuickDetails(it, ep.timeRemainingOrRuntime)
|
QuickDetails(it, ep.timeRemainingOrRuntime, Modifier.padding(start = 8.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dto != null) {
|
if (dto != null) {
|
||||||
VideoStreamDetails(
|
VideoStreamDetails(
|
||||||
chosenStreams = chosenStreams,
|
chosenStreams = chosenStreams,
|
||||||
numberOfVersions = dto.mediaSourceCount ?: 0,
|
numberOfVersions = dto.mediaSourceCount ?: 0,
|
||||||
modifier = Modifier,
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
OverviewText(
|
OverviewText(
|
||||||
|
|
|
||||||
|
|
@ -120,13 +120,13 @@ fun SeriesDetails(
|
||||||
|
|
||||||
when (val state = loading) {
|
when (val state = loading) {
|
||||||
is LoadingState.Error -> {
|
is LoadingState.Error -> {
|
||||||
ErrorMessage(state)
|
ErrorMessage(state, modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Loading,
|
LoadingState.Loading,
|
||||||
LoadingState.Pending,
|
LoadingState.Pending,
|
||||||
-> {
|
-> {
|
||||||
LoadingPage()
|
LoadingPage(modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Success -> {
|
LoadingState.Success -> {
|
||||||
|
|
@ -621,7 +621,7 @@ fun SeriesDetailsHeader(
|
||||||
) {
|
) {
|
||||||
QuickDetails(series.ui.quickDetails, null, Modifier.padding(start = 8.dp))
|
QuickDetails(series.ui.quickDetails, null, Modifier.padding(start = 8.dp))
|
||||||
dto.genres?.letNotEmpty {
|
dto.genres?.letNotEmpty {
|
||||||
GenreText(it, Modifier.padding(start = 8.dp, bottom = 12.dp))
|
GenreText(it, Modifier.padding(start = 8.dp, bottom = 8.dp))
|
||||||
}
|
}
|
||||||
dto.overview?.let { overview ->
|
dto.overview?.let { overview ->
|
||||||
OverviewText(
|
OverviewText(
|
||||||
|
|
|
||||||
|
|
@ -148,13 +148,13 @@ fun SeriesOverview(
|
||||||
|
|
||||||
when (val state = loading) {
|
when (val state = loading) {
|
||||||
is LoadingState.Error -> {
|
is LoadingState.Error -> {
|
||||||
ErrorMessage(state)
|
ErrorMessage(state, modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Loading,
|
LoadingState.Loading,
|
||||||
LoadingState.Pending,
|
LoadingState.Pending,
|
||||||
-> {
|
-> {
|
||||||
LoadingPage()
|
LoadingPage(modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Success -> {
|
LoadingState.Success -> {
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,7 @@ fun SeriesOverviewContent(
|
||||||
.onFocusChanged { pageHasFocus = it.hasFocus },
|
.onFocusChanged { pageHasFocus = it.hasFocus },
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.focusGroup()
|
.focusGroup()
|
||||||
|
|
@ -159,9 +159,10 @@ fun SeriesOverviewContent(
|
||||||
Modifier
|
Modifier
|
||||||
.focusRequester(tabRowFocusRequester)
|
.focusRequester(tabRowFocusRequester)
|
||||||
.padding(paddingValues)
|
.padding(paddingValues)
|
||||||
|
.padding(bottom = 4.dp)
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
SeriesName(series.name, Modifier)
|
SeriesName(series.name, Modifier.padding(start = 8.dp))
|
||||||
FocusedEpisodeHeader(
|
FocusedEpisodeHeader(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
ep = focusedEpisode,
|
ep = focusedEpisode,
|
||||||
|
|
@ -266,6 +267,7 @@ fun SeriesOverviewContent(
|
||||||
},
|
},
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
cardHeight = 120.dp,
|
cardHeight = 120.dp,
|
||||||
|
useSeriesForPrimary = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -294,8 +296,8 @@ fun SeriesOverviewContent(
|
||||||
},
|
},
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.padding(top = 4.dp)
|
||||||
.padding(start = 16.dp),
|
.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package com.github.damontecres.wholphin.ui.discover
|
package com.github.damontecres.wholphin.ui.discover
|
||||||
|
|
||||||
import android.content.Context
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
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.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
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.focusRequester
|
||||||
import androidx.compose.ui.focus.focusRestorer
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
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.main.HomePageHeader
|
||||||
import com.github.damontecres.wholphin.ui.rememberPosition
|
import com.github.damontecres.wholphin.ui.rememberPosition
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
|
||||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||||
import com.google.common.cache.CacheBuilder
|
import com.google.common.cache.CacheBuilder
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
|
@ -186,6 +191,7 @@ data class DiscoverState(
|
||||||
val upcomingTv: DiscoverRowData = DiscoverRowData.EMPTY,
|
val upcomingTv: DiscoverRowData = DiscoverRowData.EMPTY,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SeerrDiscoverPage(
|
fun SeerrDiscoverPage(
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
|
|
@ -249,28 +255,41 @@ fun SeerrDiscoverPage(
|
||||||
.padding(top = 24.dp, bottom = 16.dp, start = 32.dp)
|
.padding(top = 24.dp, bottom = 16.dp, start = 32.dp)
|
||||||
.fillMaxHeight(.25f),
|
.fillMaxHeight(.25f),
|
||||||
)
|
)
|
||||||
LazyColumn(
|
val density = LocalDensity.current
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
val spaceAbovePx =
|
||||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 40.dp),
|
with(density) {
|
||||||
modifier =
|
// The size of the row titles & spacing
|
||||||
Modifier
|
50.dp.toPx()
|
||||||
.focusRestorer()
|
}
|
||||||
.fillMaxSize(),
|
val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx),
|
||||||
) {
|
) {
|
||||||
itemsIndexed(rows) { rowIndex, row ->
|
LazyColumn(
|
||||||
DiscoverRow(
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
row = row,
|
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 40.dp),
|
||||||
onClickItem = { index, item ->
|
modifier =
|
||||||
position = RowColumn(rowIndex, index)
|
Modifier
|
||||||
viewModel.navigationManager.navigateTo(item.destination)
|
.focusRestorer()
|
||||||
},
|
.fillMaxSize(),
|
||||||
onLongClickItem = { index, item -> },
|
) {
|
||||||
onCardFocus = { index -> position = RowColumn(rowIndex, index) },
|
itemsIndexed(rows) { rowIndex, row ->
|
||||||
focusRequester = focusRequesters[rowIndex],
|
CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) {
|
||||||
modifier =
|
DiscoverRow(
|
||||||
Modifier
|
row = row,
|
||||||
.fillMaxWidth(),
|
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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ class SeerrRequestsViewModel
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
backdropService.clearBackdrop()
|
backdropService.clearBackdrop()
|
||||||
}
|
}
|
||||||
seerrServerRepository.current
|
seerrServerRepository.connection
|
||||||
.onEach { user ->
|
.onEach { user ->
|
||||||
state.update { it.copy(requests = DataLoadingState.Loading) }
|
state.update { it.copy(requests = DataLoadingState.Loading) }
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
package com.github.damontecres.wholphin.ui.main
|
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.focusGroup
|
||||||
import androidx.compose.foundation.focusable
|
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
|
@ -16,10 +16,13 @@ import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
import androidx.compose.runtime.mutableStateOf
|
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.focusRequester
|
||||||
import androidx.compose.ui.focus.focusRestorer
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.input.key.onKeyEvent
|
import androidx.compose.ui.input.key.onKeyEvent
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
|
@ -44,16 +47,19 @@ import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
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.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
|
||||||
import com.github.damontecres.wholphin.ui.Cards
|
import com.github.damontecres.wholphin.ui.Cards
|
||||||
import com.github.damontecres.wholphin.ui.cards.BannerCard
|
import com.github.damontecres.wholphin.ui.cards.BannerCard
|
||||||
|
import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle
|
||||||
|
import com.github.damontecres.wholphin.ui.cards.GenreCard
|
||||||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||||
import com.github.damontecres.wholphin.ui.components.CircularProgress
|
import com.github.damontecres.wholphin.ui.components.CircularProgress
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||||
import com.github.damontecres.wholphin.ui.components.EpisodeName
|
import com.github.damontecres.wholphin.ui.components.EpisodeName
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
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.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
||||||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||||
|
|
@ -67,8 +73,10 @@ import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp
|
import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp
|
||||||
import com.github.damontecres.wholphin.ui.playback.playable
|
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.rememberPosition
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
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.HomeRowLoadingState
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
@ -89,34 +97,37 @@ fun HomePage(
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.init()
|
viewModel.init()
|
||||||
}
|
}
|
||||||
val loading by viewModel.loadingState.observeAsState(LoadingState.Loading)
|
val state by viewModel.state.collectAsState()
|
||||||
val refreshing by viewModel.refreshState.observeAsState(LoadingState.Loading)
|
val loading = state.loadingState
|
||||||
val watchingRows by viewModel.watchingRows.observeAsState(listOf())
|
val refreshing = state.refreshState
|
||||||
val latestRows by viewModel.latestRows.observeAsState(listOf())
|
val homeRows = state.homeRows
|
||||||
|
|
||||||
val homeRows = remember(watchingRows, latestRows) { watchingRows + latestRows }
|
|
||||||
|
|
||||||
when (val state = loading) {
|
when (val state = loading) {
|
||||||
is LoadingState.Error -> {
|
is LoadingState.Error -> {
|
||||||
ErrorMessage(state)
|
ErrorMessage(state, modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Loading,
|
LoadingState.Loading,
|
||||||
LoadingState.Pending,
|
LoadingState.Pending,
|
||||||
-> {
|
-> {
|
||||||
LoadingPage()
|
LoadingPage(modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Success -> {
|
LoadingState.Success -> {
|
||||||
var dialog by remember { mutableStateOf<DialogParams?>(null) }
|
var dialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||||
var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) }
|
var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) }
|
||||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||||
|
var position by rememberPosition()
|
||||||
HomePageContent(
|
HomePageContent(
|
||||||
homeRows = homeRows,
|
homeRows = homeRows,
|
||||||
onClickItem = { position, item ->
|
position = position,
|
||||||
|
onFocusPosition = { position = it },
|
||||||
|
onClickItem = { clickedPosition, item ->
|
||||||
|
position = clickedPosition
|
||||||
viewModel.navigationManager.navigateTo(item.destination())
|
viewModel.navigationManager.navigateTo(item.destination())
|
||||||
},
|
},
|
||||||
onLongClickItem = { position, item ->
|
onLongClickItem = { clickedPosition, item ->
|
||||||
|
position = clickedPosition
|
||||||
val dialogItems =
|
val dialogItems =
|
||||||
buildMoreDialogItemsForHome(
|
buildMoreDialogItemsForHome(
|
||||||
context = context,
|
context = context,
|
||||||
|
|
@ -183,43 +194,48 @@ fun HomePage(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HomePageContent(
|
fun HomePageContent(
|
||||||
homeRows: List<HomeRowLoadingState>,
|
homeRows: List<HomeRowLoadingState>,
|
||||||
|
position: RowColumn,
|
||||||
|
onFocusPosition: (RowColumn) -> Unit,
|
||||||
onClickItem: (RowColumn, BaseItem) -> Unit,
|
onClickItem: (RowColumn, BaseItem) -> Unit,
|
||||||
onLongClickItem: (RowColumn, BaseItem) -> Unit,
|
onLongClickItem: (RowColumn, BaseItem) -> Unit,
|
||||||
onClickPlay: (RowColumn, BaseItem) -> Unit,
|
onClickPlay: (RowColumn, BaseItem) -> Unit,
|
||||||
showClock: Boolean,
|
showClock: Boolean,
|
||||||
onUpdateBackdrop: (BaseItem) -> Unit,
|
onUpdateBackdrop: (BaseItem) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onFocusPosition: ((RowColumn) -> Unit)? = null,
|
|
||||||
loadingState: LoadingState? = null,
|
loadingState: LoadingState? = null,
|
||||||
|
listState: LazyListState = rememberLazyListState(),
|
||||||
|
takeFocus: Boolean = true,
|
||||||
|
showEmptyRows: Boolean = false,
|
||||||
) {
|
) {
|
||||||
var position by rememberPosition()
|
|
||||||
val focusedItem =
|
val focusedItem =
|
||||||
position.let {
|
position.let {
|
||||||
(homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column)
|
(homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column)
|
||||||
}
|
}
|
||||||
|
|
||||||
val listState = rememberLazyListState()
|
|
||||||
val rowFocusRequesters = remember(homeRows) { List(homeRows.size) { FocusRequester() } }
|
val rowFocusRequesters = remember(homeRows) { List(homeRows.size) { FocusRequester() } }
|
||||||
var firstFocused by remember { mutableStateOf(false) }
|
var firstFocused by remember { mutableStateOf(false) }
|
||||||
LaunchedEffect(homeRows) {
|
if (takeFocus) {
|
||||||
if (!firstFocused && homeRows.isNotEmpty()) {
|
LaunchedEffect(homeRows) {
|
||||||
if (position.row >= 0) {
|
if (!firstFocused && homeRows.isNotEmpty()) {
|
||||||
val index = position.row.coerceIn(0, rowFocusRequesters.lastIndex)
|
if (position.row >= 0) {
|
||||||
rowFocusRequesters.getOrNull(index)?.tryRequestFocus()
|
val index = position.row.coerceIn(0, rowFocusRequesters.lastIndex)
|
||||||
firstFocused = true
|
rowFocusRequesters.getOrNull(index)?.tryRequestFocus()
|
||||||
} else {
|
firstFocused = true
|
||||||
// Waiting for the first home row to load, then focus on it
|
} else {
|
||||||
homeRows
|
// Waiting for the first home row to load, then focus on it
|
||||||
.indexOfFirstOrNull { it is HomeRowLoadingState.Success && it.items.isNotEmpty() }
|
homeRows
|
||||||
?.let {
|
.indexOfFirstOrNull { it is HomeRowLoadingState.Success && it.items.isNotEmpty() }
|
||||||
rowFocusRequesters[it].tryRequestFocus()
|
?.let {
|
||||||
firstFocused = true
|
rowFocusRequesters[it].tryRequestFocus()
|
||||||
delay(50)
|
firstFocused = true
|
||||||
listState.scrollToItem(it)
|
delay(50)
|
||||||
}
|
listState.scrollToItem(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -240,134 +256,110 @@ fun HomePageContent(
|
||||||
.padding(top = 48.dp, bottom = 32.dp, start = 8.dp)
|
.padding(top = 48.dp, bottom = 32.dp, start = 8.dp)
|
||||||
.fillMaxHeight(.33f),
|
.fillMaxHeight(.33f),
|
||||||
)
|
)
|
||||||
LazyColumn(
|
val density = LocalDensity.current
|
||||||
state = listState,
|
val spaceAbovePx =
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
with(density) {
|
||||||
contentPadding =
|
// The size of the row titles & spacing
|
||||||
PaddingValues(
|
50.dp.toPx()
|
||||||
bottom = Cards.height2x3,
|
}
|
||||||
),
|
val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current
|
||||||
modifier =
|
CompositionLocalProvider(
|
||||||
Modifier
|
LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx),
|
||||||
.focusRestorer(),
|
|
||||||
) {
|
) {
|
||||||
itemsIndexed(homeRows) { rowIndex, row ->
|
LazyColumn(
|
||||||
when (val r = row) {
|
state = listState,
|
||||||
is HomeRowLoadingState.Loading,
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
is HomeRowLoadingState.Pending,
|
contentPadding =
|
||||||
-> {
|
PaddingValues(
|
||||||
Column(
|
bottom = Cards.height2x3,
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
),
|
||||||
modifier = Modifier.animateItem(),
|
modifier =
|
||||||
) {
|
Modifier
|
||||||
Text(
|
.focusRestorer(),
|
||||||
text = r.title,
|
) {
|
||||||
style = MaterialTheme.typography.titleLarge,
|
itemsIndexed(homeRows) { rowIndex, row ->
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
CompositionLocalProvider(
|
||||||
)
|
LocalBringIntoViewSpec provides defaultBringIntoViewSpec,
|
||||||
Text(
|
) {
|
||||||
text = stringResource(R.string.loading),
|
when (val r = row) {
|
||||||
style = MaterialTheme.typography.titleMedium,
|
is HomeRowLoadingState.Loading,
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
is HomeRowLoadingState.Pending,
|
||||||
)
|
-> {
|
||||||
}
|
FocusableItemRow(
|
||||||
}
|
title = r.title,
|
||||||
|
subtitle = stringResource(R.string.loading),
|
||||||
|
modifier = Modifier.animateItem(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
is HomeRowLoadingState.Error -> {
|
is HomeRowLoadingState.Error -> {
|
||||||
var focused by remember { mutableStateOf(false) }
|
FocusableItemRow(
|
||||||
Column(
|
title = r.title,
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
subtitle = r.localizedMessage,
|
||||||
modifier =
|
isError = true,
|
||||||
Modifier
|
modifier = Modifier.animateItem(),
|
||||||
.onFocusChanged {
|
)
|
||||||
focused = it.isFocused
|
}
|
||||||
}.focusable()
|
|
||||||
.background(
|
is HomeRowLoadingState.Success -> {
|
||||||
if (focused) {
|
if (row.items.isNotEmpty()) {
|
||||||
// Just so the user can tell it has focus
|
val viewOptions = row.viewOptions
|
||||||
MaterialTheme.colorScheme.border.copy(alpha = .25f)
|
ItemRow(
|
||||||
} else {
|
title = row.title,
|
||||||
Color.Unspecified
|
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?.episodeUnplayedCornerText,
|
|
||||||
played = item?.data?.userData?.played ?: false,
|
|
||||||
favorite = item?.favorite ?: false,
|
|
||||||
playPercent =
|
|
||||||
item?.data?.userData?.playedPercentage
|
|
||||||
?: 0.0,
|
|
||||||
onClick = onClick,
|
|
||||||
onLongClick = onLongClick,
|
|
||||||
modifier =
|
modifier =
|
||||||
cardModifier
|
Modifier
|
||||||
.onFocusChanged {
|
.fillMaxWidth()
|
||||||
if (it.isFocused) {
|
.focusGroup()
|
||||||
position = RowColumn(rowIndex, index)
|
.focusRequester(rowFocusRequesters[rowIndex])
|
||||||
// item?.let(onUpdateBackdrop)
|
.animateItem(),
|
||||||
}
|
horizontalPadding = viewOptions.spacing.dp,
|
||||||
if (it.isFocused && onFocusPosition != null) {
|
cardContent = { index, item, cardModifier, onClick, onLongClick ->
|
||||||
val nonEmptyRowBefore =
|
HomePageCardContent(
|
||||||
homeRows
|
index = index,
|
||||||
.subList(0, rowIndex)
|
item = item,
|
||||||
.count {
|
onClick = onClick,
|
||||||
it is HomeRowLoadingState.Success && it.items.isEmpty()
|
onLongClick = onLongClick,
|
||||||
}
|
viewOptions = viewOptions,
|
||||||
onFocusPosition.invoke(
|
modifier =
|
||||||
RowColumn(
|
cardModifier
|
||||||
rowIndex - nonEmptyRowBefore,
|
.onFocusChanged {
|
||||||
index,
|
if (it.isFocused) {
|
||||||
),
|
onFocusPosition?.invoke(
|
||||||
)
|
RowColumn(rowIndex, index),
|
||||||
}
|
)
|
||||||
}.onKeyEvent {
|
}
|
||||||
if (isPlayKeyUp(it) && item?.type?.playable == true) {
|
}.onKeyEvent {
|
||||||
Timber.v("Clicked play on ${item.id}")
|
if (isPlayKeyUp(it) && item?.type?.playable == true) {
|
||||||
onClickPlay.invoke(position, item)
|
Timber.v("Clicked play on ${item.id}")
|
||||||
return@onKeyEvent true
|
onClickPlay.invoke(
|
||||||
}
|
position,
|
||||||
return@onKeyEvent false
|
item,
|
||||||
},
|
)
|
||||||
interactionSource = null,
|
return@onKeyEvent true
|
||||||
cardHeight = Cards.height2x3,
|
}
|
||||||
|
return@onKeyEvent false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
} else if (showEmptyRows) {
|
||||||
)
|
FocusableItemRow(
|
||||||
|
title = r.title,
|
||||||
|
subtitle = stringResource(R.string.no_results),
|
||||||
|
modifier = Modifier.animateItem(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -406,7 +398,7 @@ fun HomePageHeader(
|
||||||
subtitle = if (isEpisode) dto?.name else null,
|
subtitle = if (isEpisode) dto?.name else null,
|
||||||
overview = dto?.overview,
|
overview = dto?.overview,
|
||||||
overviewTwoLines = isEpisode,
|
overviewTwoLines = isEpisode,
|
||||||
quickDetails = item?.ui?.quickDetails,
|
quickDetails = item?.ui?.quickDetails ?: AnnotatedString(""),
|
||||||
timeRemaining = item?.timeRemainingOrRuntime,
|
timeRemaining = item?.timeRemainingOrRuntime,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
|
|
@ -467,3 +459,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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,39 @@
|
||||||
package com.github.damontecres.wholphin.ui.main
|
package com.github.damontecres.wholphin.ui.main
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
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.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
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.services.BackdropService
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.DatePlayedService
|
import com.github.damontecres.wholphin.services.DatePlayedService
|
||||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
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.MediaReportService
|
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.NavigationManager
|
||||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||||
|
import com.github.damontecres.wholphin.services.tvAccess
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
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.ui.showToast
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.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.launch
|
||||||
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
|
import kotlinx.coroutines.sync.withPermit
|
||||||
import kotlinx.coroutines.withContext
|
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 timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -41,115 +43,122 @@ class HomeViewModel
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
val api: ApiClient,
|
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
val serverRepository: ServerRepository,
|
val serverRepository: ServerRepository,
|
||||||
val navDrawerItemRepository: NavDrawerItemRepository,
|
|
||||||
val mediaReportService: MediaReportService,
|
val mediaReportService: MediaReportService,
|
||||||
|
private val navDrawerService: NavDrawerService,
|
||||||
|
private val homeSettingsService: HomeSettingsService,
|
||||||
private val favoriteWatchManager: FavoriteWatchManager,
|
private val favoriteWatchManager: FavoriteWatchManager,
|
||||||
private val datePlayedService: DatePlayedService,
|
private val datePlayedService: DatePlayedService,
|
||||||
private val latestNextUpService: LatestNextUpService,
|
|
||||||
private val backdropService: BackdropService,
|
private val backdropService: BackdropService,
|
||||||
private val userPreferencesService: UserPreferencesService,
|
private val userPreferencesService: UserPreferencesService,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val loadingState = MutableLiveData<LoadingState>(LoadingState.Pending)
|
private val _state = MutableStateFlow(HomeState.EMPTY)
|
||||||
val refreshState = MutableLiveData<LoadingState>(LoadingState.Pending)
|
val state: StateFlow<HomeState> = _state
|
||||||
val watchingRows = MutableLiveData<List<HomeRowLoadingState>>(listOf())
|
|
||||||
val latestRows = MutableLiveData<List<HomeRowLoadingState>>(listOf())
|
|
||||||
|
|
||||||
private lateinit var preferences: UserPreferences
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
datePlayedService.invalidateAll()
|
datePlayedService.invalidateAll()
|
||||||
init()
|
// init()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun init() {
|
fun init() {
|
||||||
viewModelScope.launch(
|
viewModelScope.launchIO {
|
||||||
Dispatchers.IO +
|
|
||||||
LoadingExceptionHandler(
|
|
||||||
loadingState,
|
|
||||||
"Error loading home page",
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Timber.d("init HomeViewModel")
|
Timber.d("init HomeViewModel")
|
||||||
val reload = loadingState.value != LoadingState.Success
|
|
||||||
if (reload) {
|
|
||||||
loadingState.setValueOnMain(LoadingState.Loading)
|
|
||||||
}
|
|
||||||
refreshState.setValueOnMain(LoadingState.Loading)
|
|
||||||
this@HomeViewModel.preferences = userPreferencesService.getCurrent()
|
|
||||||
val prefs = preferences.appPreferences.homePagePreferences
|
|
||||||
val limit = prefs.maxItemsPerRow
|
|
||||||
if (reload) {
|
|
||||||
backdropService.clearBackdrop()
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
|
val preferences = userPreferencesService.getCurrent()
|
||||||
|
val prefs = preferences.appPreferences.homePagePreferences
|
||||||
|
|
||||||
serverRepository.currentUserDto.value?.let { userDto ->
|
serverRepository.currentUserDto.value?.let { userDto ->
|
||||||
val includedIds =
|
val libraries =
|
||||||
navDrawerItemRepository
|
navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess)
|
||||||
.getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems())
|
val settings =
|
||||||
.filter { it is ServerNavDrawerItem }
|
homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY }
|
||||||
.map { (it as ServerNavDrawerItem).itemId }
|
val state = state.value
|
||||||
val resume = latestNextUpService.getResume(userDto.id, limit, true)
|
|
||||||
val nextUp =
|
// Refreshing if a load has already occurred and the rows haven't significantly changed
|
||||||
latestNextUpService.getNextUp(
|
val refresh =
|
||||||
userDto.id,
|
state.loadingState == LoadingState.Success && state.settings == settings
|
||||||
limit,
|
Timber.v("refresh=$refresh, state.loadingState=${state.loadingState}")
|
||||||
prefs.enableRewatchingNextUp,
|
_state.update { it.copy(settings = settings) }
|
||||||
false,
|
|
||||||
prefs.maxDaysNextUp,
|
val semaphore = Semaphore(4)
|
||||||
)
|
|
||||||
val watching =
|
val watchingRowIndexes =
|
||||||
buildList {
|
settings.rows
|
||||||
if (prefs.combineContinueNext) {
|
.mapIndexedNotNull { index, row ->
|
||||||
val items = latestNextUpService.buildCombined(resume, nextUp)
|
if (isWatchingRow(row.config)) index else null
|
||||||
add(
|
}
|
||||||
HomeRowLoadingState.Success(
|
val deferred =
|
||||||
title = context.getString(R.string.continue_watching),
|
settings.rows
|
||||||
items = items,
|
// Load the watching rows first
|
||||||
),
|
.sortedByDescending { isWatchingRow(it.config) }
|
||||||
)
|
.map { row ->
|
||||||
} else {
|
viewModelScope.async(Dispatchers.IO) {
|
||||||
if (resume.isNotEmpty()) {
|
semaphore.withPermit {
|
||||||
add(
|
Timber.v("Fetching row: %s", row)
|
||||||
HomeRowLoadingState.Success(
|
try {
|
||||||
title = context.getString(R.string.continue_watching),
|
homeSettingsService.fetchDataForRow(
|
||||||
items = resume,
|
row = row.config,
|
||||||
),
|
scope = viewModelScope,
|
||||||
)
|
prefs = prefs,
|
||||||
}
|
userDto = userDto,
|
||||||
if (nextUp.isNotEmpty()) {
|
libraries = libraries,
|
||||||
add(
|
limit = prefs.maxItemsPerRow,
|
||||||
HomeRowLoadingState.Success(
|
isRefresh = refresh,
|
||||||
title = context.getString(R.string.next_up),
|
)
|
||||||
items = nextUp,
|
} catch (ex: Exception) {
|
||||||
),
|
Timber.e(ex, "Error on row %s", row)
|
||||||
)
|
HomeRowLoadingState.Error(row.title, exception = ex)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
val latest = latestNextUpService.getLatest(userDto, limit, includedIds)
|
if (refresh && state.homeRows.isNotEmpty() && watchingRowIndexes.isNotEmpty()) {
|
||||||
val pendingLatest = latest.map { HomeRowLoadingState.Loading(it.title) }
|
// Replace watching rows first
|
||||||
|
Timber.v("Refreshing rows: %s", watchingRowIndexes)
|
||||||
withContext(Dispatchers.Main) {
|
val rows =
|
||||||
this@HomeViewModel.watchingRows.value = watching
|
deferred
|
||||||
if (reload) {
|
.filterIndexed { index, _ -> index in watchingRowIndexes }
|
||||||
this@HomeViewModel.latestRows.value = pendingLatest
|
.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 rows =
|
||||||
val loadedLatest = latestNextUpService.loadLatest(latest)
|
deferred
|
||||||
this@HomeViewModel.latestRows.setValueOnMain(loadedLatest)
|
.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) {
|
} catch (ex: Exception) {
|
||||||
Timber.e(ex)
|
Timber.e(ex, "Exception during home page loading")
|
||||||
if (!reload) {
|
if (state.value.loadingState == LoadingState.Success) {
|
||||||
loadingState.setValueOnMain(LoadingState.Error(ex))
|
|
||||||
} else {
|
|
||||||
showToast(context, "Error refreshing home: ${ex.localizedMessage}")
|
showToast(context, "Error refreshing home: ${ex.localizedMessage}")
|
||||||
|
} else {
|
||||||
|
_state.update {
|
||||||
|
it.copy(loadingState = LoadingState.Error(ex))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -182,16 +191,27 @@ class HomeViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val supportedLatestCollectionTypes =
|
data class HomeState(
|
||||||
setOf(
|
val loadingState: LoadingState,
|
||||||
CollectionType.MOVIES,
|
val refreshState: LoadingState,
|
||||||
CollectionType.TVSHOWS,
|
val homeRows: List<HomeRowLoadingState>,
|
||||||
CollectionType.HOMEVIDEOS,
|
val settings: HomePageResolvedSettings,
|
||||||
// Exclude Live TV because a recording folder view will be used instead
|
) {
|
||||||
null, // Recordings & mixed collection types
|
companion object {
|
||||||
)
|
val EMPTY =
|
||||||
|
HomeState(
|
||||||
|
LoadingState.Pending,
|
||||||
|
LoadingState.Pending,
|
||||||
|
listOf(),
|
||||||
|
HomePageResolvedSettings.EMPTY,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data class LatestData(
|
/**
|
||||||
val title: String,
|
* Whether a row is a "is watching" type
|
||||||
val request: GetLatestMediaRequest,
|
*/
|
||||||
)
|
private fun isWatchingRow(row: HomeRowConfig) =
|
||||||
|
row is HomeRowConfig.ContinueWatching ||
|
||||||
|
row is HomeRowConfig.NextUp ||
|
||||||
|
row is HomeRowConfig.ContinueWatchingCombined
|
||||||
|
|
|
||||||
|
|
@ -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<LibraryRowType> {
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
|
@ -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]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<PreferenceGroup<HomeRowViewOptions>>,
|
||||||
|
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<HomeRowViewOptions, Any>
|
||||||
|
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<HomeRowViewOptions>(
|
||||||
|
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<HomeRowViewOptions>(
|
||||||
|
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<HomeRowViewOptions, PrefContentScale>(
|
||||||
|
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<HomeRowViewOptions, AspectRatio>(
|
||||||
|
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<HomeRowViewOptions>(
|
||||||
|
title = R.string.show_titles,
|
||||||
|
defaultValue = true,
|
||||||
|
getter = { it.showTitles },
|
||||||
|
setter = { vo, value -> vo.copy(showTitles = value) },
|
||||||
|
)
|
||||||
|
|
||||||
|
val ViewOptionsUseSeries =
|
||||||
|
AppSwitchPreference<HomeRowViewOptions>(
|
||||||
|
title = R.string.use_series,
|
||||||
|
defaultValue = true,
|
||||||
|
getter = { it.useSeries },
|
||||||
|
setter = { vo, value -> vo.copy(useSeries = value) },
|
||||||
|
)
|
||||||
|
|
||||||
|
val ViewOptionsImageType =
|
||||||
|
AppChoicePreference<HomeRowViewOptions, ViewOptionImageType>(
|
||||||
|
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<HomeRowViewOptions>(
|
||||||
|
title = R.string.apply_all_rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
val ViewOptionsReset =
|
||||||
|
AppClickablePreference<HomeRowViewOptions>(
|
||||||
|
title = R.string.reset,
|
||||||
|
)
|
||||||
|
|
||||||
|
val ViewOptionsUseThumb =
|
||||||
|
AppClickablePreference<HomeRowViewOptions>(
|
||||||
|
title = R.string.use_thumb_images,
|
||||||
|
)
|
||||||
|
|
||||||
|
val ViewOptionsEpisodeContentScale =
|
||||||
|
AppChoicePreference<HomeRowViewOptions, PrefContentScale>(
|
||||||
|
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<HomeRowViewOptions, AspectRatio>(
|
||||||
|
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<HomeRowViewOptions, ViewOptionImageType>(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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<Library>,
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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() }
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,344 @@
|
||||||
|
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<ShowConfirm?>(null) }
|
||||||
|
var searchForDialog by remember { mutableStateOf<BaseItemKind?>(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) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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<AppPreferences>,
|
||||||
|
@param:IoCoroutineScope private val ioScope: CoroutineScope,
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _state = MutableStateFlow(HomePageSettingsState.EMPTY)
|
||||||
|
val state: StateFlow<HomePageSettingsState> = _state
|
||||||
|
|
||||||
|
private var idCounter by Delegates.notNull<Int>()
|
||||||
|
|
||||||
|
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 <T> List<T>.move(
|
||||||
|
direction: MoveDirection,
|
||||||
|
index: Int,
|
||||||
|
): List<T> =
|
||||||
|
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<UUID, CollectionType?>()
|
||||||
|
|
||||||
|
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<HomeRowConfigDisplay>,
|
||||||
|
val rowData: List<HomeRowLoadingState>,
|
||||||
|
val libraries: List<Library>,
|
||||||
|
) {
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
@ -33,6 +33,9 @@ sealed class Destination(
|
||||||
val id: Long = 0L,
|
val id: Long = 0L,
|
||||||
) : Destination()
|
) : Destination()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object HomeSettings : Destination(true)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Settings(
|
data class Settings(
|
||||||
val screen: PreferenceScreenOption,
|
val screen: PreferenceScreenOption,
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview
|
||||||
import com.github.damontecres.wholphin.ui.discover.DiscoverPage
|
import com.github.damontecres.wholphin.ui.discover.DiscoverPage
|
||||||
import com.github.damontecres.wholphin.ui.main.HomePage
|
import com.github.damontecres.wholphin.ui.main.HomePage
|
||||||
import com.github.damontecres.wholphin.ui.main.SearchPage
|
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.playback.PlaybackPage
|
||||||
import com.github.damontecres.wholphin.ui.preferences.PreferencesPage
|
import com.github.damontecres.wholphin.ui.preferences.PreferencesPage
|
||||||
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage
|
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage
|
||||||
|
|
@ -66,6 +67,10 @@ fun DestinationContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is Destination.HomeSettings -> {
|
||||||
|
HomeSettingsPage(modifier)
|
||||||
|
}
|
||||||
|
|
||||||
is Destination.PlaybackList,
|
is Destination.PlaybackList,
|
||||||
is Destination.Playback,
|
is Destination.Playback,
|
||||||
-> {
|
-> {
|
||||||
|
|
@ -119,7 +124,9 @@ fun DestinationContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
BaseItemKind.VIDEO -> {
|
BaseItemKind.VIDEO,
|
||||||
|
BaseItemKind.MUSIC_VIDEO,
|
||||||
|
-> {
|
||||||
// TODO Use VideoDetails
|
// TODO Use VideoDetails
|
||||||
MovieDetails(
|
MovieDetails(
|
||||||
preferences,
|
preferences,
|
||||||
|
|
@ -220,7 +227,7 @@ fun DestinationContent(
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
Timber.w("Unsupported item type: ${destination.type}")
|
Timber.w("Unsupported item type: ${destination.type}")
|
||||||
Text("Unsupported item type: ${destination.type}")
|
Text("Unsupported item type: ${destination.type}", modifier)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.ReadOnlyComposable
|
import androidx.compose.runtime.ReadOnlyComposable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
|
@ -66,20 +67,19 @@ import androidx.tv.material3.ProvideTextStyle
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import androidx.tv.material3.surfaceColorAtElevation
|
import androidx.tv.material3.surfaceColorAtElevation
|
||||||
import com.github.damontecres.wholphin.R
|
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.JellyfinServer
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
import com.github.damontecres.wholphin.preferences.AppThemeColors
|
import com.github.damontecres.wholphin.preferences.AppThemeColors
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.BackdropService
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
|
import com.github.damontecres.wholphin.services.NavDrawerService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
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.SetupDestination
|
||||||
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
||||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||||
import com.github.damontecres.wholphin.ui.components.TimeDisplay
|
import com.github.damontecres.wholphin.ui.components.TimeDisplay
|
||||||
import com.github.damontecres.wholphin.ui.ifElse
|
import com.github.damontecres.wholphin.ui.ifElse
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
|
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||||
import com.github.damontecres.wholphin.ui.setup.UserIconCardImage
|
import com.github.damontecres.wholphin.ui.setup.UserIconCardImage
|
||||||
|
|
@ -88,10 +88,6 @@ import com.github.damontecres.wholphin.ui.theme.LocalTheme
|
||||||
import com.github.damontecres.wholphin.ui.toServerString
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.launchIn
|
|
||||||
import kotlinx.coroutines.flow.onEach
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||||
import org.jellyfin.sdk.model.api.CollectionType
|
import org.jellyfin.sdk.model.api.CollectionType
|
||||||
|
|
@ -104,42 +100,66 @@ class NavDrawerViewModel
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
private val api: ApiClient,
|
private val api: ApiClient,
|
||||||
private val navDrawerItemRepository: NavDrawerItemRepository,
|
private val navDrawerService: NavDrawerService,
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
val setupNavigationManager: SetupNavigationManager,
|
val setupNavigationManager: SetupNavigationManager,
|
||||||
val backdropService: BackdropService,
|
val backdropService: BackdropService,
|
||||||
private val seerrServerRepository: SeerrServerRepository,
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val moreLibraries = MutableLiveData<List<NavDrawerItem>>(null)
|
val state = navDrawerService.state
|
||||||
val libraries = MutableLiveData<List<NavDrawerItem>>(listOf())
|
|
||||||
|
|
||||||
val selectedIndex = MutableLiveData(-1)
|
val selectedIndex = MutableLiveData(-1)
|
||||||
val showMore = MutableLiveData(false)
|
val moreExpanded = MutableLiveData(false)
|
||||||
|
|
||||||
init {
|
fun onClickDrawerItem(
|
||||||
seerrServerRepository.active
|
index: Int,
|
||||||
.onEach {
|
item: NavDrawerItem,
|
||||||
init()
|
) {
|
||||||
}.launchIn(viewModelScope)
|
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() {
|
fun setIndex(index: Int) {
|
||||||
viewModelScope.launchIO {
|
selectedIndex.value = index
|
||||||
val all = navDrawerItemRepository.getNavDrawerItems()
|
}
|
||||||
val libraries = navDrawerItemRepository.getFilteredNavDrawerItems(all)
|
|
||||||
val moreLibraries = all.toMutableList().apply { removeAll(libraries) }
|
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
fun setShowMore(value: Boolean) {
|
||||||
this@NavDrawerViewModel.moreLibraries.value = moreLibraries
|
moreExpanded.value = value
|
||||||
this@NavDrawerViewModel.libraries.value = libraries
|
}
|
||||||
}
|
|
||||||
|
fun getUserImage(user: JellyfinUser): String = api.imageApi.getUserImageUrl(user.id)
|
||||||
|
|
||||||
|
fun updateSelectedIndex() {
|
||||||
|
viewModelScope.launchDefault {
|
||||||
val asDestinations =
|
val asDestinations =
|
||||||
(
|
(
|
||||||
libraries +
|
state.value.items +
|
||||||
listOf(
|
listOf(
|
||||||
NavDrawerItem.More,
|
NavDrawerItem.More,
|
||||||
NavDrawerItem.Discover,
|
NavDrawerItem.Discover,
|
||||||
) + moreLibraries
|
) + state.value.moreItems
|
||||||
).map {
|
).map {
|
||||||
if (it is ServerNavDrawerItem) {
|
if (it is ServerNavDrawerItem) {
|
||||||
it.destination
|
it.destination
|
||||||
|
|
@ -178,52 +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)
|
|
||||||
}
|
|
||||||
|
|
||||||
NavDrawerItem.NowPlaying -> {
|
|
||||||
setIndex(index)
|
|
||||||
navigationManager.navigateToFromDrawer(Destination.NowPlaying)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
sealed interface NavDrawerItem {
|
||||||
|
|
@ -251,13 +225,6 @@ sealed interface NavDrawerItem {
|
||||||
|
|
||||||
override fun name(context: Context): String = context.getString(R.string.discover)
|
override fun name(context: Context): String = context.getString(R.string.discover)
|
||||||
}
|
}
|
||||||
|
|
||||||
object NowPlaying : NavDrawerItem {
|
|
||||||
override val id: String
|
|
||||||
get() = "a_nowplaying"
|
|
||||||
|
|
||||||
override fun name(context: Context): String = context.getString(R.string.now_playing)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ServerNavDrawerItem(
|
data class ServerNavDrawerItem(
|
||||||
|
|
@ -266,9 +233,13 @@ data class ServerNavDrawerItem(
|
||||||
val destination: Destination,
|
val destination: Destination,
|
||||||
val type: CollectionType,
|
val type: CollectionType,
|
||||||
) : NavDrawerItem {
|
) : NavDrawerItem {
|
||||||
override val id: String = "s_" + itemId.toServerString()
|
override val id: String = getId(itemId)
|
||||||
|
|
||||||
override fun name(context: Context): String = name
|
override fun name(context: Context): String = name
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun getId(itemId: UUID) = "s_" + itemId.toServerString()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -290,6 +261,7 @@ fun NavDrawer(
|
||||||
key = "${server.id}_${user.id}", // Keyed to the server & user to ensure its reset when switching either
|
key = "${server.id}_${user.id}", // Keyed to the server & user to ensure its reset when switching either
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
|
LaunchedEffect(Unit) { viewModel.updateSelectedIndex() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
|
@ -301,15 +273,12 @@ fun NavDrawer(
|
||||||
drawerState.setValue(DrawerValue.Open)
|
drawerState.setValue(DrawerValue.Open)
|
||||||
focusRequester.requestFocus()
|
focusRequester.requestFocus()
|
||||||
}
|
}
|
||||||
val moreLibraries by viewModel.moreLibraries.observeAsState(listOf())
|
val state by viewModel.state.collectAsState()
|
||||||
val libraries by viewModel.libraries.observeAsState(listOf())
|
val moreExpanded by viewModel.moreExpanded.observeAsState(false)
|
||||||
LaunchedEffect(Unit) { viewModel.init() }
|
// A negative index is a built-in page, >=0 is a library
|
||||||
|
|
||||||
val showMore by viewModel.showMore.observeAsState(false)
|
|
||||||
// A negative index is a built in page, >=0 is a library
|
|
||||||
val selectedIndex by viewModel.selectedIndex.observeAsState(-1)
|
val selectedIndex by viewModel.selectedIndex.observeAsState(-1)
|
||||||
|
|
||||||
BackHandler(enabled = showMore && drawerState.currentValue == DrawerValue.Open) {
|
BackHandler(enabled = moreExpanded && drawerState.currentValue == DrawerValue.Open) {
|
||||||
viewModel.setShowMore(false)
|
viewModel.setShowMore(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -424,51 +393,83 @@ fun NavDrawer(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
itemsIndexed(libraries) { index, it ->
|
itemsIndexed(state.items) { index, it ->
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
if (it !is NavDrawerItem.Discover || state.discoverEnabled) {
|
||||||
NavItem(
|
|
||||||
library = it,
|
|
||||||
selected = selectedIndex == index,
|
|
||||||
moreExpanded = showMore,
|
|
||||||
drawerOpen = 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)
|
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
NavItem(
|
NavItem(
|
||||||
library = it,
|
library = it,
|
||||||
selected = selectedIndex == adjustedIndex,
|
selected = selectedIndex == index,
|
||||||
moreExpanded = showMore,
|
moreExpanded = moreExpanded,
|
||||||
drawerOpen = isOpen,
|
drawerOpen = isOpen,
|
||||||
onClick = { viewModel.onClickDrawerItem(adjustedIndex, it) },
|
|
||||||
containerColor =
|
|
||||||
if (isOpen) {
|
|
||||||
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)
|
|
||||||
} else {
|
|
||||||
Color.Unspecified
|
|
||||||
},
|
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
|
onClick = {
|
||||||
|
viewModel.onClickDrawerItem(index, it)
|
||||||
|
},
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.ifElse(
|
.ifElse(
|
||||||
selectedIndex == adjustedIndex,
|
selectedIndex == index,
|
||||||
Modifier.focusRequester(focusRequester),
|
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 {
|
item {
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
IconNavItem(
|
IconNavItem(
|
||||||
|
|
@ -637,10 +638,6 @@ fun NavigationDrawerScope.NavItem(
|
||||||
else -> R.string.fa_film
|
else -> R.string.fa_film
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
NavDrawerItem.NowPlaying -> {
|
|
||||||
R.string.fa_circle_play
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val focused by interactionSource.collectIsFocusedAsState()
|
val focused by interactionSource.collectIsFocusedAsState()
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
package com.github.damontecres.wholphin.ui.playback
|
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 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.api.TrickplayInfo
|
||||||
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
|
|
||||||
data class CurrentMediaInfo(
|
data class CurrentMediaInfo(
|
||||||
val sourceId: String?,
|
val sourceId: String?,
|
||||||
|
|
@ -15,3 +20,22 @@ data class CurrentMediaInfo(
|
||||||
val EMPTY = CurrentMediaInfo(null, null, listOf(), listOf(), listOf(), null)
|
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()
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ fun DownloadSubtitlesContent(
|
||||||
.padding(PaddingValues(24.dp)),
|
.padding(PaddingValues(24.dp)),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Search & download subtitles",
|
text = stringResource(R.string.search_and_download_subtitles),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
|
|
@ -113,7 +113,7 @@ fun DownloadSubtitlesContent(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Language",
|
text = stringResource(R.string.language),
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
|
|
@ -137,7 +137,7 @@ fun DownloadSubtitlesContent(
|
||||||
}
|
}
|
||||||
if (dialogItems.isEmpty()) {
|
if (dialogItems.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = "No remote subtitles were found",
|
text = stringResource(R.string.no_subtitles_found),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,22 @@
|
||||||
package com.github.damontecres.wholphin.ui.playback
|
package com.github.damontecres.wholphin.ui.playback
|
||||||
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.preferences.PrefContentScale
|
import com.github.damontecres.wholphin.preferences.PrefContentScale
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
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 playbackSpeedOptions = listOf(".25", ".5", ".75", "1.0", "1.25", "1.5", "1.75", "2.0")
|
||||||
|
|
||||||
val playbackScaleOptions =
|
val playbackScaleOptions =
|
||||||
mapOf(
|
mapOf(
|
||||||
ContentScale.Fit to "Fit",
|
ContentScale.Fit to R.string.content_scale_fit,
|
||||||
ContentScale.None to "None",
|
ContentScale.None to R.string.none,
|
||||||
ContentScale.Crop to "Crop",
|
ContentScale.Crop to R.string.content_scale_crop,
|
||||||
// ContentScale.Inside to "Inside",
|
// ContentScale.Inside to "Inside",
|
||||||
ContentScale.FillBounds to "Fill",
|
ContentScale.FillBounds to R.string.content_scale_fill,
|
||||||
ContentScale.FillWidth to "Fill Width",
|
ContentScale.FillWidth to R.string.content_scale_fill_width,
|
||||||
ContentScale.FillHeight to "Fill Height",
|
ContentScale.FillHeight to R.string.content_scale_fill_height,
|
||||||
)
|
)
|
||||||
|
|
||||||
val PrefContentScale.scale: ContentScale
|
val PrefContentScale.scale: ContentScale
|
||||||
|
|
@ -81,3 +83,20 @@ val BaseItemKind.playable: Boolean
|
||||||
BaseItemKind.YEAR,
|
BaseItemKind.YEAR,
|
||||||
-> false
|
-> 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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,9 @@ fun PlaybackDialog(
|
||||||
BottomDialogItem(
|
BottomDialogItem(
|
||||||
data = PlaybackDialogType.VIDEO_SCALE,
|
data = PlaybackDialogType.VIDEO_SCALE,
|
||||||
headline = stringResource(R.string.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) ->
|
playbackScaleOptions.map { (scale, name) ->
|
||||||
BottomDialogItem(
|
BottomDialogItem(
|
||||||
data = scale,
|
data = scale,
|
||||||
headline = name,
|
headline = stringResource(name),
|
||||||
supporting = null,
|
supporting = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.github.damontecres.wholphin.ui.playback
|
package com.github.damontecres.wholphin.ui.playback
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.slideInVertically
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
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.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
|
@ -32,6 +38,7 @@ import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusProperties
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.focusRestorer
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
|
@ -251,8 +258,34 @@ fun PlaybackOverlay(
|
||||||
exit = slideOutVertically { it / 2 } + fadeOut(),
|
exit = slideOutVertically { it / 2 } + fadeOut(),
|
||||||
) {
|
) {
|
||||||
if (chapters.isNotEmpty()) {
|
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() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
LaunchedEffect(Unit) {
|
||||||
|
bringIntoViewRequester.bringIntoView()
|
||||||
|
focusRequester.tryRequestFocus()
|
||||||
|
}
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier =
|
modifier =
|
||||||
|
|
@ -273,6 +306,7 @@ fun PlaybackOverlay(
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
)
|
)
|
||||||
LazyRow(
|
LazyRow(
|
||||||
|
state = listState,
|
||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
modifier =
|
modifier =
|
||||||
|
|
@ -302,10 +336,19 @@ fun PlaybackOverlay(
|
||||||
},
|
},
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.ifElse(
|
Modifier
|
||||||
index == 0,
|
.ifElse(
|
||||||
Modifier.focusRequester(focusRequester),
|
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(),
|
text = (seekProgressMs / 1000L).seconds.toString(),
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
style = MaterialTheme.typography.labelLarge,
|
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() },
|
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
onNextStateFocus: () -> Unit = {},
|
onNextStateFocus: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
|
val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState()
|
||||||
|
val verticalOffset by animateDpAsState(
|
||||||
|
targetValue = if (seekBarFocused) (-32).dp else 0.dp,
|
||||||
|
label = "TitleBumpOffset",
|
||||||
|
)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = Modifier.padding(start = 16.dp),
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(start = 16.dp)
|
||||||
|
.offset(y = verticalOffset),
|
||||||
) {
|
) {
|
||||||
title?.let {
|
title?.let {
|
||||||
Text(
|
Text(
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,6 @@ import com.github.damontecres.wholphin.util.mpv.MpvPlayer
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
@ -163,8 +162,7 @@ fun PlaybackPageContent(
|
||||||
itemId = UUID.randomUUID(),
|
itemId = UUID.randomUUID(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val currentSegment by viewModel.currentSegment.observeAsState(null)
|
val currentSegment by viewModel.currentSegment.collectAsState()
|
||||||
var segmentCancelled by remember(currentSegment?.id) { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val cues by viewModel.subtitleCues.observeAsState(listOf())
|
val cues by viewModel.subtitleCues.observeAsState(listOf())
|
||||||
var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) }
|
var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) }
|
||||||
|
|
@ -302,10 +300,10 @@ fun PlaybackPageContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
val showSegment =
|
val showSegment =
|
||||||
!segmentCancelled && currentSegment != null &&
|
currentSegment?.interacted == false &&
|
||||||
nextUp == null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L
|
nextUp == null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L
|
||||||
BackHandler(showSegment) {
|
BackHandler(showSegment) {
|
||||||
segmentCancelled = true
|
viewModel.updateSegment(currentSegment?.segment?.id, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
|
|
@ -400,7 +398,7 @@ fun PlaybackPageContent(
|
||||||
onClickPlaylist = {
|
onClickPlaylist = {
|
||||||
viewModel.playItemInPlaylist(it)
|
viewModel.playItemInPlaylist(it)
|
||||||
},
|
},
|
||||||
currentSegment = currentSegment,
|
currentSegment = currentSegment?.segment,
|
||||||
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -462,13 +460,12 @@ fun PlaybackPageContent(
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
focusRequester.tryRequestFocus()
|
focusRequester.tryRequestFocus()
|
||||||
delay(10.seconds)
|
delay(10.seconds)
|
||||||
segmentCancelled = true
|
viewModel.updateSegment(segment.segment.id, true)
|
||||||
}
|
}
|
||||||
TextButton(
|
TextButton(
|
||||||
stringRes = segment.type.skipStringRes,
|
stringRes = segment.segment.type.skipStringRes,
|
||||||
onClick = {
|
onClick = {
|
||||||
segmentCancelled = true
|
viewModel.updateSegment(segment.segment.id, false)
|
||||||
player.seekTo(segment.endTicks.ticks.inWholeMilliseconds)
|
|
||||||
},
|
},
|
||||||
modifier = Modifier.focusRequester(focusRequester),
|
modifier = Modifier.focusRequester(focusRequester),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.DatePlayedService
|
import com.github.damontecres.wholphin.services.DatePlayedService
|
||||||
import com.github.damontecres.wholphin.services.DeviceProfileService
|
import com.github.damontecres.wholphin.services.DeviceProfileService
|
||||||
|
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.services.PlayerFactory
|
import com.github.damontecres.wholphin.services.PlayerFactory
|
||||||
import com.github.damontecres.wholphin.services.PlaylistCreationResult
|
import com.github.damontecres.wholphin.services.PlaylistCreationResult
|
||||||
|
|
@ -49,6 +50,7 @@ import com.github.damontecres.wholphin.services.RefreshRateService
|
||||||
import com.github.damontecres.wholphin.services.StreamChoiceService
|
import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
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.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.onMain
|
import com.github.damontecres.wholphin.ui.onMain
|
||||||
|
|
@ -57,7 +59,6 @@ import com.github.damontecres.wholphin.ui.seekForward
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||||
import com.github.damontecres.wholphin.ui.showToast
|
import com.github.damontecres.wholphin.ui.showToast
|
||||||
import com.github.damontecres.wholphin.ui.toServerString
|
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.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener
|
import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener
|
||||||
|
|
@ -95,6 +96,7 @@ import org.jellyfin.sdk.api.client.extensions.videosApi
|
||||||
import org.jellyfin.sdk.api.sockets.subscribe
|
import org.jellyfin.sdk.api.sockets.subscribe
|
||||||
import org.jellyfin.sdk.model.DeviceInfo
|
import org.jellyfin.sdk.model.DeviceInfo
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
import org.jellyfin.sdk.model.api.MediaSegmentDto
|
import org.jellyfin.sdk.model.api.MediaSegmentDto
|
||||||
import org.jellyfin.sdk.model.api.MediaSegmentType
|
import org.jellyfin.sdk.model.api.MediaSegmentType
|
||||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
|
|
@ -137,6 +139,7 @@ class PlaybackViewModel
|
||||||
private val refreshRateService: RefreshRateService,
|
private val refreshRateService: RefreshRateService,
|
||||||
val streamChoiceService: StreamChoiceService,
|
val streamChoiceService: StreamChoiceService,
|
||||||
private val userPreferencesService: UserPreferencesService,
|
private val userPreferencesService: UserPreferencesService,
|
||||||
|
private val imageUrlService: ImageUrlService,
|
||||||
@Assisted private val destination: Destination,
|
@Assisted private val destination: Destination,
|
||||||
) : ViewModel(),
|
) : ViewModel(),
|
||||||
Player.Listener,
|
Player.Listener,
|
||||||
|
|
@ -164,8 +167,7 @@ class PlaybackViewModel
|
||||||
val currentMediaInfo = MutableLiveData<CurrentMediaInfo>(CurrentMediaInfo.EMPTY)
|
val currentMediaInfo = MutableLiveData<CurrentMediaInfo>(CurrentMediaInfo.EMPTY)
|
||||||
val currentPlayback = MutableStateFlow<CurrentPlayback?>(null)
|
val currentPlayback = MutableStateFlow<CurrentPlayback?>(null)
|
||||||
val currentItemPlayback = MutableLiveData<ItemPlayback>()
|
val currentItemPlayback = MutableLiveData<ItemPlayback>()
|
||||||
val currentSegment = EqualityMutableLiveData<MediaSegmentDto?>(null)
|
val currentSegment = MutableStateFlow<MediaSegmentState?>(null)
|
||||||
private val autoSkippedSegments = mutableSetOf<UUID>()
|
|
||||||
|
|
||||||
val subtitleCues = MutableLiveData<List<Cue>>(listOf())
|
val subtitleCues = MutableLiveData<List<Cue>>(listOf())
|
||||||
|
|
||||||
|
|
@ -672,7 +674,15 @@ class PlaybackViewModel
|
||||||
MediaItem
|
MediaItem
|
||||||
.Builder()
|
.Builder()
|
||||||
.setMediaId(itemId.toString())
|
.setMediaId(itemId.toString())
|
||||||
.setUri(mediaUrl.toUri())
|
.setMediaMetadata(
|
||||||
|
item.toMediaMetadata(
|
||||||
|
imageUrlService.getItemImageUrl(
|
||||||
|
item,
|
||||||
|
ImageType.PRIMARY,
|
||||||
|
useSeriesForPrimary = true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).setUri(mediaUrl.toUri())
|
||||||
.setSubtitleConfigurations(listOfNotNull(externalSubtitle))
|
.setSubtitleConfigurations(listOfNotNull(externalSubtitle))
|
||||||
.apply {
|
.apply {
|
||||||
when (source.container) {
|
when (source.container) {
|
||||||
|
|
@ -957,15 +967,19 @@ class PlaybackViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Variables for tracking segment state
|
||||||
private var segmentJob: Job? = null
|
private var segmentJob: Job? = null
|
||||||
|
private val autoSkippedSegments = mutableSetOf<UUID>()
|
||||||
|
private val outroShownSegments = mutableSetOf<UUID>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cancels listening for segments and clears current segment state
|
* Cancels listening for segments and clears current segment state
|
||||||
*/
|
*/
|
||||||
private suspend fun resetSegmentState() {
|
private fun resetSegmentState() {
|
||||||
segmentJob?.cancel()
|
segmentJob?.cancel()
|
||||||
autoSkippedSegments.clear()
|
autoSkippedSegments.clear()
|
||||||
currentSegment.setValueOnMain(null)
|
outroShownSegments.clear()
|
||||||
|
currentSegment.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -988,20 +1002,27 @@ class PlaybackViewModel
|
||||||
it.type != MediaSegmentType.UNKNOWN && currentTicks >= it.startTicks && currentTicks < it.endTicks
|
it.type != MediaSegmentType.UNKNOWN && currentTicks >= it.startTicks && currentTicks < it.endTicks
|
||||||
}
|
}
|
||||||
if (currentSegment != null &&
|
if (currentSegment != null &&
|
||||||
currentSegment.itemId == this@PlaybackViewModel.itemId &&
|
currentSegment.itemId == this@PlaybackViewModel.itemId
|
||||||
autoSkippedSegments.add(currentSegment.id)
|
|
||||||
) {
|
) {
|
||||||
Timber.d(
|
if (currentSegment.id !=
|
||||||
"Found media segment for %s: %s, %s",
|
this@PlaybackViewModel
|
||||||
currentSegment.itemId,
|
.currentSegment.value
|
||||||
currentSegment.id,
|
?.segment
|
||||||
currentSegment.type,
|
?.id
|
||||||
)
|
) {
|
||||||
|
Timber.d(
|
||||||
|
"Found media segment for %s: %s, %s",
|
||||||
|
currentSegment.itemId,
|
||||||
|
currentSegment.id,
|
||||||
|
currentSegment.type,
|
||||||
|
)
|
||||||
|
}
|
||||||
val playlist = this@PlaybackViewModel.playlist.value
|
val playlist = this@PlaybackViewModel.playlist.value
|
||||||
|
|
||||||
if (currentSegment.type == MediaSegmentType.OUTRO &&
|
if (currentSegment.type == MediaSegmentType.OUTRO &&
|
||||||
prefs.showNextUpWhen == ShowNextUpWhen.DURING_CREDITS &&
|
prefs.showNextUpWhen == ShowNextUpWhen.DURING_CREDITS &&
|
||||||
playlist != null && playlist.hasNext()
|
playlist != null && playlist.hasNext() &&
|
||||||
|
outroShownSegments.add(currentSegment.id)
|
||||||
) {
|
) {
|
||||||
val nextItem = playlist.peek()
|
val nextItem = playlist.peek()
|
||||||
Timber.v("Setting next up during outro to ${nextItem?.id}")
|
Timber.v("Setting next up during outro to ${nextItem?.id}")
|
||||||
|
|
@ -1021,13 +1042,21 @@ class PlaybackViewModel
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
when (behavior) {
|
when (behavior) {
|
||||||
SkipSegmentBehavior.AUTO_SKIP -> {
|
SkipSegmentBehavior.AUTO_SKIP -> {
|
||||||
this@PlaybackViewModel.currentSegment.value = null
|
if (autoSkippedSegments.add(currentSegment.id)) {
|
||||||
player.seekTo(currentSegment.endTicks.ticks.inWholeMilliseconds + 1)
|
onMain { player.seekTo(currentSegment.endTicks.ticks.inWholeMilliseconds + 1) }
|
||||||
|
}
|
||||||
|
this@PlaybackViewModel.currentSegment.update {
|
||||||
|
MediaSegmentState(currentSegment, true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SkipSegmentBehavior.ASK_TO_SKIP -> {
|
SkipSegmentBehavior.ASK_TO_SKIP -> {
|
||||||
this@PlaybackViewModel.currentSegment.value =
|
this@PlaybackViewModel.currentSegment.update {
|
||||||
currentSegment
|
MediaSegmentState(
|
||||||
|
currentSegment,
|
||||||
|
autoSkippedSegments.contains(currentSegment.id),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
|
|
@ -1046,6 +1075,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 =
|
private fun listenForTranscodeReason(): Job =
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
currentPlayback.collectLatest {
|
currentPlayback.collectLatest {
|
||||||
|
|
@ -1379,3 +1430,8 @@ data class PlayerState(
|
||||||
val player: Player,
|
val player: Player,
|
||||||
val backend: PlayerBackend,
|
val backend: PlayerBackend,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class MediaSegmentState(
|
||||||
|
val segment: MediaSegmentDto,
|
||||||
|
val interacted: Boolean,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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<NavDrawerItem, Boolean>,
|
||||||
|
) {
|
||||||
|
items.map { (item, pinned) ->
|
||||||
|
NavDrawerPin(item.id, item.name(context), pinned, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class MoveDirection {
|
||||||
|
UP,
|
||||||
|
DOWN,
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> List<T>.move(
|
||||||
|
direction: MoveDirection,
|
||||||
|
index: Int,
|
||||||
|
): List<T> =
|
||||||
|
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<NavDrawerPin>,
|
||||||
|
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<AppPreferences>,
|
||||||
|
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<List<NavDrawerPin>>(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<NavDrawerPin>) {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,8 +11,10 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
|
@ -51,6 +53,7 @@ import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||||
import com.github.damontecres.wholphin.preferences.advancedPreferences
|
import com.github.damontecres.wholphin.preferences.advancedPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.basicPreferences
|
import com.github.damontecres.wholphin.preferences.basicPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences
|
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.services.UpdateChecker
|
||||||
import com.github.damontecres.wholphin.ui.components.ConfirmDialog
|
import com.github.damontecres.wholphin.ui.components.ConfirmDialog
|
||||||
import com.github.damontecres.wholphin.ui.ifElse
|
import com.github.damontecres.wholphin.ui.ifElse
|
||||||
|
|
@ -89,10 +92,10 @@ fun PreferencesContent(
|
||||||
val currentServer by seerrVm.currentSeerrServer.collectAsState(null)
|
val currentServer by seerrVm.currentSeerrServer.collectAsState(null)
|
||||||
var showPinFlow by remember { mutableStateOf(false) }
|
var showPinFlow by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val navDrawerPins by viewModel.navDrawerPins.observeAsState(mapOf())
|
|
||||||
var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) }
|
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>(SeerrDialogMode.None) }
|
var seerrDialogMode by remember { mutableStateOf<SeerrDialogMode>(SeerrDialogMode.None) }
|
||||||
|
var showQuickConnectDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.preferenceDataStore.data.collect {
|
viewModel.preferenceDataStore.data.collect {
|
||||||
|
|
@ -165,291 +168,310 @@ fun PreferencesContent(
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
focusRequester.tryRequestFocus()
|
focusRequester.tryRequestFocus()
|
||||||
}
|
}
|
||||||
LazyColumn(
|
Column(
|
||||||
state = state,
|
|
||||||
horizontalAlignment = Alignment.Start,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
|
||||||
contentPadding = PaddingValues(16.dp),
|
|
||||||
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)),
|
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)),
|
||||||
) {
|
) {
|
||||||
stickyHeader {
|
Text(
|
||||||
Text(
|
text = stringResource(screenTitle),
|
||||||
text = stringResource(screenTitle),
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
textAlign = TextAlign.Center,
|
||||||
textAlign = TextAlign.Center,
|
modifier =
|
||||||
modifier =
|
Modifier
|
||||||
Modifier
|
.fillMaxWidth()
|
||||||
.fillMaxWidth()
|
.padding(vertical = 8.dp),
|
||||||
.padding(vertical = 8.dp),
|
)
|
||||||
)
|
LazyColumn(
|
||||||
}
|
state = state,
|
||||||
if (UpdateChecker.ACTIVE &&
|
horizontalAlignment = Alignment.Start,
|
||||||
preferenceScreenOption == PreferenceScreenOption.BASIC &&
|
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||||
preferences.autoCheckForUpdates &&
|
contentPadding = PaddingValues(16.dp),
|
||||||
updateAvailable
|
modifier = Modifier.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
item {
|
if (UpdateChecker.ACTIVE &&
|
||||||
val updateFocusRequester = remember { FocusRequester() }
|
preferenceScreenOption == PreferenceScreenOption.BASIC &&
|
||||||
LaunchedEffect(Unit) {
|
preferences.autoCheckForUpdates &&
|
||||||
if (focusedIndex.first == 0 && focusedIndex.second == 0) {
|
updateAvailable
|
||||||
// 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<AppPreferences, Any>
|
|
||||||
item {
|
item {
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val updateFocusRequester = remember { FocusRequester() }
|
||||||
val focused = interactionSource.collectIsFocusedAsState().value
|
LaunchedEffect(Unit) {
|
||||||
LaunchedEffect(focused) {
|
if (focusedIndex.first == 0 && focusedIndex.second == 0) {
|
||||||
if (focused) {
|
// Only re-focus if the user hasn't moved
|
||||||
focusedIndex = Pair(groupIndex, prefIndex)
|
updateFocusRequester.tryRequestFocus()
|
||||||
if (movementSounds) playOnClickSound(context)
|
|
||||||
onFocus.invoke(groupIndex, prefIndex)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
when (pref) {
|
ClickPreference(
|
||||||
AppPreference.InstalledVersion -> {
|
title = stringResource(R.string.install_update),
|
||||||
var clickCount by remember { mutableIntStateOf(0) }
|
onClick = {
|
||||||
ClickPreference(
|
if (movementSounds) playOnClickSound(context)
|
||||||
title = stringResource(R.string.installed_version),
|
viewModel.navigationManager.navigateTo(Destination.UpdateApp)
|
||||||
onClick = {
|
},
|
||||||
if (movementSounds) playOnClickSound(context)
|
summary = release?.version?.toString(),
|
||||||
if (clickCount++ >= 2) {
|
modifier =
|
||||||
clickCount = 0
|
Modifier
|
||||||
viewModel.navigationManager.navigateTo(Destination.Debug)
|
.focusRequester(updateFocusRequester)
|
||||||
}
|
.playSoundOnFocus(movementSounds),
|
||||||
},
|
)
|
||||||
summary = installedVersion.toString(),
|
}
|
||||||
interactionSource = interactionSource,
|
}
|
||||||
modifier =
|
prefList.forEachIndexed { groupIndex, group ->
|
||||||
Modifier
|
item {
|
||||||
.ifElse(
|
Text(
|
||||||
groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
|
text = stringResource(group.title),
|
||||||
Modifier.focusRequester(focusRequester),
|
style = MaterialTheme.typography.titleMedium,
|
||||||
),
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
textAlign = TextAlign.Start,
|
||||||
}
|
modifier =
|
||||||
|
Modifier
|
||||||
AppPreference.Update -> {
|
.fillMaxWidth()
|
||||||
ClickPreference(
|
.padding(top = 8.dp, bottom = 4.dp),
|
||||||
title =
|
)
|
||||||
if (release != null && updateAvailable) {
|
}
|
||||||
stringResource(R.string.install_update)
|
val groupPreferences =
|
||||||
} else if (!preferences.autoCheckForUpdates && release == null) {
|
group.preferences +
|
||||||
stringResource(R.string.check_for_updates)
|
group.conditionalPreferences
|
||||||
} else {
|
.filter { it.condition.invoke(preferences) }
|
||||||
stringResource(R.string.no_update_available)
|
.map { it.preferences }
|
||||||
},
|
.flatten()
|
||||||
onClick = {
|
groupPreferences.forEachIndexed { prefIndex, pref ->
|
||||||
if (movementSounds) playOnClickSound(context)
|
pref as AppPreference<AppPreferences, Any>
|
||||||
if (release != null && updateAvailable) {
|
item {
|
||||||
release?.let {
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
viewModel.navigationManager.navigateTo(Destination.UpdateApp)
|
val focused = interactionSource.collectIsFocusedAsState().value
|
||||||
}
|
LaunchedEffect(focused) {
|
||||||
} else {
|
if (focused) {
|
||||||
updateVM.init(preferences.updateUrl)
|
focusedIndex = Pair(groupIndex, prefIndex)
|
||||||
}
|
if (movementSounds) playOnClickSound(context)
|
||||||
},
|
onFocus.invoke(groupIndex, prefIndex)
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
when (pref) {
|
||||||
AppPreference.SendAppLogs -> {
|
AppPreference.InstalledVersion -> {
|
||||||
ClickPreference(
|
var clickCount by remember { mutableIntStateOf(0) }
|
||||||
title = stringResource(pref.title),
|
ClickPreference(
|
||||||
onClick = {
|
title = stringResource(R.string.installed_version),
|
||||||
viewModel.sendAppLogs()
|
onClick = {
|
||||||
},
|
if (movementSounds) playOnClickSound(context)
|
||||||
modifier = Modifier,
|
if (clickCount++ >= 2) {
|
||||||
summary = pref.summary(context, null),
|
clickCount = 0
|
||||||
onLongClick = {},
|
viewModel.navigationManager.navigateTo(Destination.Debug)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
summary = installedVersion.toString(),
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.ifElse(
|
||||||
|
groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
|
||||||
|
Modifier.focusRequester(focusRequester),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
PreferenceValidation.Valid -> {
|
AppPreference.Update -> {
|
||||||
scope.launch(ExceptionHandler()) {
|
ClickPreference(
|
||||||
preferences =
|
title =
|
||||||
viewModel.preferenceDataStore.updateData { prefs ->
|
if (release != null && updateAvailable) {
|
||||||
pref.setter(prefs, newValue)
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
interactionSource = interactionSource,
|
modifier =
|
||||||
modifier =
|
Modifier
|
||||||
Modifier
|
.ifElse(
|
||||||
.ifElse(
|
groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
|
||||||
groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
|
Modifier.focusRequester(focusRequester),
|
||||||
Modifier.focusRequester(focusRequester),
|
),
|
||||||
),
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -472,11 +494,11 @@ fun PreferencesContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
when (seerrDialogMode) {
|
when (val mode = seerrDialogMode) {
|
||||||
SeerrDialogMode.Remove -> {
|
is SeerrDialogMode.Remove -> {
|
||||||
ConfirmDialog(
|
ConfirmDialog(
|
||||||
title = stringResource(R.string.remove_seerr_server),
|
title = stringResource(R.string.remove_seerr_server),
|
||||||
body = currentServer?.url ?: "",
|
body = mode.serverUrl,
|
||||||
onCancel = { seerrDialogMode = SeerrDialogMode.None },
|
onCancel = { seerrDialogMode = SeerrDialogMode.None },
|
||||||
onConfirm = {
|
onConfirm = {
|
||||||
seerrVm.removeServer()
|
seerrVm.removeServer()
|
||||||
|
|
@ -503,9 +525,62 @@ fun PreferencesContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 -> {}
|
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
|
@Composable
|
||||||
|
|
@ -542,10 +617,17 @@ data class CacheUsage(
|
||||||
val imageDiskUsed: Long,
|
val imageDiskUsed: Long,
|
||||||
)
|
)
|
||||||
|
|
||||||
private sealed class SeerrDialogMode {
|
private sealed interface SeerrDialogMode {
|
||||||
data object None : 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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,10 @@ package com.github.damontecres.wholphin.ui.preferences
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.asFlow
|
|
||||||
import androidx.lifecycle.viewModelScope
|
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.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.isPinned
|
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
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.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.resetSubtitles
|
import com.github.damontecres.wholphin.preferences.resetSubtitles
|
||||||
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
|
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
|
||||||
|
|
@ -21,13 +14,13 @@ import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.services.SeerrServerRepository
|
import com.github.damontecres.wholphin.services.SeerrServerRepository
|
||||||
import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs
|
import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
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.ExceptionHandler
|
||||||
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import com.github.damontecres.wholphin.util.RememberTabManager
|
import com.github.damontecres.wholphin.util.RememberTabManager
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.model.ClientInfo
|
import org.jellyfin.sdk.model.ClientInfo
|
||||||
import org.jellyfin.sdk.model.DeviceInfo
|
import org.jellyfin.sdk.model.DeviceInfo
|
||||||
|
|
@ -44,62 +37,22 @@ class PreferencesViewModel
|
||||||
val backdropService: BackdropService,
|
val backdropService: BackdropService,
|
||||||
private val rememberTabManager: RememberTabManager,
|
private val rememberTabManager: RememberTabManager,
|
||||||
private val serverRepository: ServerRepository,
|
private val serverRepository: ServerRepository,
|
||||||
private val navDrawerItemRepository: NavDrawerItemRepository,
|
|
||||||
private val serverPreferencesDao: ServerPreferencesDao,
|
|
||||||
private val seerrServerRepository: SeerrServerRepository,
|
private val seerrServerRepository: SeerrServerRepository,
|
||||||
private val deviceInfo: DeviceInfo,
|
private val deviceInfo: DeviceInfo,
|
||||||
private val clientInfo: ClientInfo,
|
private val clientInfo: ClientInfo,
|
||||||
) : ViewModel(),
|
) : ViewModel(),
|
||||||
RememberTabManager by rememberTabManager {
|
RememberTabManager by rememberTabManager {
|
||||||
private lateinit var allNavDrawerItems: List<NavDrawerItem>
|
|
||||||
val navDrawerPins = MutableLiveData<Map<NavDrawerItem, Boolean>>(mapOf())
|
|
||||||
|
|
||||||
val currentUser get() = serverRepository.currentUser
|
val currentUser get() = serverRepository.currentUser
|
||||||
|
|
||||||
val seerrEnabled =
|
val seerrConnection = seerrServerRepository.connection
|
||||||
seerrServerRepository.currentUser.combine(currentUser.asFlow()) { seerrUser, jellyfinUser ->
|
|
||||||
seerrUser != null && jellyfinUser != null && seerrUser.jellyfinUserRowId == jellyfinUser.rowId
|
private val _quickConnectStatus = MutableStateFlow<LoadingState>(LoadingState.Pending)
|
||||||
}
|
val quickConnectStatus: StateFlow<LoadingState> = _quickConnectStatus
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
serverRepository.currentUser.value?.let { user ->
|
serverRepository.currentUser.value?.let { user ->
|
||||||
allNavDrawerItems = navDrawerItemRepository.getNavDrawerItems()
|
// fetchNavDrawerPins(user)
|
||||||
val pins = serverPreferencesDao.getNavDrawerPinnedItems(user)
|
|
||||||
val navDrawerPins = allNavDrawerItems.associateWith { pins.isPinned(it.id) }
|
|
||||||
this@PreferencesViewModel.navDrawerPins.setValueOnMain(navDrawerPins)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updatePins(newSelectedItems: List<NavDrawerItem>) {
|
|
||||||
viewModelScope.launchIO(ExceptionHandler(true)) {
|
|
||||||
serverRepository.currentUser.value?.let { user ->
|
|
||||||
val disabledItems =
|
|
||||||
mutableListOf<NavDrawerItem>().apply {
|
|
||||||
addAll(allNavDrawerItems)
|
|
||||||
removeAll(newSelectedItems)
|
|
||||||
}
|
|
||||||
val enabledItems = newSelectedItems.toSet()
|
|
||||||
val toSave =
|
|
||||||
disabledItems.map {
|
|
||||||
NavDrawerPinnedItem(
|
|
||||||
user.rowId,
|
|
||||||
it.id,
|
|
||||||
NavPinType.UNPINNED,
|
|
||||||
)
|
|
||||||
} +
|
|
||||||
enabledItems.map {
|
|
||||||
NavDrawerPinnedItem(
|
|
||||||
user.rowId,
|
|
||||||
it.id,
|
|
||||||
NavPinType.PINNED,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
serverPreferencesDao.saveNavDrawerPinnedItems(*toSave.toTypedArray())
|
|
||||||
val pins = serverPreferencesDao.getNavDrawerPinnedItems(user)
|
|
||||||
val navDrawerPins = allNavDrawerItems.associateWith { pins.isPinned(it.id) }
|
|
||||||
this@PreferencesViewModel.navDrawerPins.setValueOnMain(navDrawerPins)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -123,6 +76,27 @@ class PreferencesViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun resetQuickConnectStatus() {
|
||||||
|
_quickConnectStatus.value = LoadingState.Pending
|
||||||
|
}
|
||||||
|
|
||||||
|
fun authorizeQuickConnect(code: String) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
_quickConnectStatus.value = LoadingState.Loading
|
||||||
|
try {
|
||||||
|
val success = serverRepository.authorizeQuickConnect(code)
|
||||||
|
_quickConnectStatus.value =
|
||||||
|
if (success) {
|
||||||
|
LoadingState.Success
|
||||||
|
} else {
|
||||||
|
LoadingState.Error("Authorization failed")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_quickConnectStatus.value = LoadingState.Error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
suspend fun resetSubtitleSettings(appPreferences: DataStore<AppPreferences>) {
|
suspend fun resetSubtitleSettings(appPreferences: DataStore<AppPreferences>) {
|
||||||
appPreferences.updateData {
|
appPreferences.updateData {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.preferences
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import androidx.tv.material3.surfaceColorAtElevation
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.ui.components.EditTextBox
|
||||||
|
import com.github.damontecres.wholphin.ui.components.TextButton
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun QuickConnectDialog(
|
||||||
|
onSubmit: (String) -> Unit,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
elevation: Dp = 3.dp,
|
||||||
|
) {
|
||||||
|
var code by remember { mutableStateOf("") }
|
||||||
|
var showError by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val isValidCode: (String) -> Boolean = { value ->
|
||||||
|
val trimmed = value.trim()
|
||||||
|
trimmed.length == 6 && trimmed.all { it.isDigit() }
|
||||||
|
}
|
||||||
|
|
||||||
|
val onSubmitCode = {
|
||||||
|
if (isValidCode(code)) {
|
||||||
|
showError = false
|
||||||
|
onSubmit(code.trim())
|
||||||
|
} else {
|
||||||
|
showError = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog(
|
||||||
|
properties =
|
||||||
|
DialogProperties(
|
||||||
|
usePlatformDefaultWidth = false,
|
||||||
|
),
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.width(360.dp)
|
||||||
|
.background(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceColorAtElevation(elevation),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(16.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.quick_connect_code),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
)
|
||||||
|
|
||||||
|
EditTextBox(
|
||||||
|
value = code,
|
||||||
|
onValueChange = {
|
||||||
|
code = it
|
||||||
|
showError = false
|
||||||
|
},
|
||||||
|
keyboardOptions =
|
||||||
|
KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Number,
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
),
|
||||||
|
keyboardActions =
|
||||||
|
KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
onSubmitCode()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
isInputValid = { value ->
|
||||||
|
!showError || isValidCode(value)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (showError) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.quick_connect_code_error),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(
|
||||||
|
stringRes = R.string.submit,
|
||||||
|
onClick = onSubmitCode,
|
||||||
|
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -62,7 +62,7 @@ fun AddSeerrServerApiKey(
|
||||||
val passwordFocusRequester = remember { FocusRequester() }
|
val passwordFocusRequester = remember { FocusRequester() }
|
||||||
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||||
Text(
|
Text(
|
||||||
text = "Enter URL & API Key",
|
text = stringResource(R.string.enter_url_api_key),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
|
|
@ -73,7 +73,7 @@ fun AddSeerrServerApiKey(
|
||||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "URL",
|
text = stringResource(R.string.url),
|
||||||
modifier = Modifier.padding(end = 8.dp),
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
)
|
)
|
||||||
EditTextBox(
|
EditTextBox(
|
||||||
|
|
@ -104,7 +104,7 @@ fun AddSeerrServerApiKey(
|
||||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "API Key",
|
text = stringResource(R.string.api_key),
|
||||||
modifier = Modifier.padding(end = 8.dp),
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
)
|
)
|
||||||
EditTextBox(
|
EditTextBox(
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
|
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
|
||||||
import com.github.damontecres.wholphin.ui.components.BasicDialog
|
import com.github.damontecres.wholphin.ui.components.BasicDialog
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogItem
|
import com.github.damontecres.wholphin.ui.components.DialogItem
|
||||||
|
|
@ -71,27 +73,26 @@ fun ChooseSeerrLoginType(
|
||||||
onChoose: (SeerrAuthMethod) -> Unit,
|
onChoose: (SeerrAuthMethod) -> Unit,
|
||||||
) {
|
) {
|
||||||
val params =
|
val params =
|
||||||
remember {
|
DialogParams(
|
||||||
DialogParams(
|
fromLongClick = false,
|
||||||
fromLongClick = false,
|
title = stringResource(R.string.seerr_login),
|
||||||
title = "Login to Seerr server",
|
items =
|
||||||
items =
|
listOf(
|
||||||
listOf(
|
DialogItem(
|
||||||
DialogItem(
|
text = stringResource(R.string.api_key),
|
||||||
text = "API Key",
|
onClick = { onChoose.invoke(SeerrAuthMethod.API_KEY) },
|
||||||
onClick = { onChoose.invoke(SeerrAuthMethod.API_KEY) },
|
|
||||||
),
|
|
||||||
DialogItem(
|
|
||||||
text = "Jellyfin user",
|
|
||||||
onClick = { onChoose.invoke(SeerrAuthMethod.JELLYFIN) },
|
|
||||||
),
|
|
||||||
DialogItem(
|
|
||||||
text = "Local user",
|
|
||||||
onClick = { onChoose.invoke(SeerrAuthMethod.LOCAL) },
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
DialogItem(
|
||||||
}
|
text = stringResource(R.string.seerr_jellyfin_user),
|
||||||
|
onClick = { onChoose.invoke(SeerrAuthMethod.JELLYFIN) },
|
||||||
|
),
|
||||||
|
DialogItem(
|
||||||
|
text = stringResource(R.string.seerr_local_user),
|
||||||
|
onClick = { onChoose.invoke(SeerrAuthMethod.LOCAL) },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
DialogPopup(
|
DialogPopup(
|
||||||
params = params,
|
params = params,
|
||||||
onDismissRequest = onDismissRequest,
|
onDismissRequest = onDismissRequest,
|
||||||
|
|
|
||||||
|
|
@ -314,11 +314,11 @@ fun SlideshowPage(
|
||||||
) {
|
) {
|
||||||
when (loadingState) {
|
when (loadingState) {
|
||||||
ImageLoadingState.Error -> {
|
ImageLoadingState.Error -> {
|
||||||
ErrorMessage("Error loading image", null)
|
ErrorMessage("Error loading image", null, modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
ImageLoadingState.Loading -> {
|
ImageLoadingState.Loading -> {
|
||||||
LoadingPage()
|
LoadingPage(modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
is ImageLoadingState.Success -> {
|
is ImageLoadingState.Success -> {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.util
|
||||||
|
|
||||||
|
import androidx.compose.foundation.gestures.BringIntoViewSpec
|
||||||
|
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overrides scrolling so that the item being scrolled to is at the top of the view offset by the provided pixels
|
||||||
|
*
|
||||||
|
* Note: the offset is necessary for anything that is focuseable, but has content before (eg a title) that needs to be displayed too
|
||||||
|
*
|
||||||
|
* Note: this applies to ALL scrollable composables within its scope, so a [LazyColumn] of [androidx.compose.foundation.lazy.LazyRow]s likely needs nested [LocalBringIntoViewSpec] overrides
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* ```kotlin
|
||||||
|
* val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current
|
||||||
|
* CompositionLocalProvider(LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx)) {
|
||||||
|
* LazyColumn{
|
||||||
|
* items(list){
|
||||||
|
* CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) {
|
||||||
|
* // Content
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
class ScrollToTopBringIntoViewSpec(
|
||||||
|
val spaceAbovePx: Float = 100f,
|
||||||
|
) : BringIntoViewSpec {
|
||||||
|
override fun calculateScrollDistance(
|
||||||
|
offset: Float,
|
||||||
|
size: Float,
|
||||||
|
containerSize: Float,
|
||||||
|
): Float {
|
||||||
|
// Timber.v(
|
||||||
|
// "calculateScrollDistance: offset=%s, size=%s, containerSize=%s",
|
||||||
|
// offset,
|
||||||
|
// size,
|
||||||
|
// containerSize,
|
||||||
|
// )
|
||||||
|
return offset - spaceAbovePx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ val supportItemKinds =
|
||||||
BaseItemKind.EPISODE,
|
BaseItemKind.EPISODE,
|
||||||
BaseItemKind.SERIES,
|
BaseItemKind.SERIES,
|
||||||
BaseItemKind.VIDEO,
|
BaseItemKind.VIDEO,
|
||||||
|
BaseItemKind.MUSIC_VIDEO,
|
||||||
BaseItemKind.SEASON,
|
BaseItemKind.SEASON,
|
||||||
BaseItemKind.COLLECTION_FOLDER,
|
BaseItemKind.COLLECTION_FOLDER,
|
||||||
BaseItemKind.FOLDER,
|
BaseItemKind.FOLDER,
|
||||||
|
|
@ -35,11 +36,20 @@ val supportedCollectionTypes =
|
||||||
null, // Mixed
|
null, // Mixed
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val supportedHomeCollectionTypes =
|
||||||
|
setOf(
|
||||||
|
CollectionType.MOVIES,
|
||||||
|
CollectionType.TVSHOWS,
|
||||||
|
CollectionType.HOMEVIDEOS,
|
||||||
|
null, // Mixed
|
||||||
|
)
|
||||||
|
|
||||||
val supportedPlayableTypes =
|
val supportedPlayableTypes =
|
||||||
setOf(
|
setOf(
|
||||||
BaseItemKind.MOVIE,
|
BaseItemKind.MOVIE,
|
||||||
BaseItemKind.EPISODE,
|
BaseItemKind.EPISODE,
|
||||||
BaseItemKind.VIDEO,
|
BaseItemKind.VIDEO,
|
||||||
|
BaseItemKind.MUSIC_VIDEO,
|
||||||
BaseItemKind.TV_CHANNEL,
|
BaseItemKind.TV_CHANNEL,
|
||||||
BaseItemKind.TV_PROGRAM,
|
BaseItemKind.TV_PROGRAM,
|
||||||
BaseItemKind.RECORDING,
|
BaseItemKind.RECORDING,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.github.damontecres.wholphin.util
|
package com.github.damontecres.wholphin.util
|
||||||
|
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic state for loading something from the API
|
* Generic state for loading something from the API
|
||||||
|
|
@ -48,6 +49,9 @@ sealed interface RowLoadingState {
|
||||||
sealed interface HomeRowLoadingState {
|
sealed interface HomeRowLoadingState {
|
||||||
val title: String
|
val title: String
|
||||||
|
|
||||||
|
val completed: Boolean
|
||||||
|
get() = this is Success || this is Error
|
||||||
|
|
||||||
data class Pending(
|
data class Pending(
|
||||||
override val title: String,
|
override val title: String,
|
||||||
) : HomeRowLoadingState
|
) : HomeRowLoadingState
|
||||||
|
|
@ -59,6 +63,7 @@ sealed interface HomeRowLoadingState {
|
||||||
data class Success(
|
data class Success(
|
||||||
override val title: String,
|
override val title: String,
|
||||||
val items: List<BaseItem?>,
|
val items: List<BaseItem?>,
|
||||||
|
val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
|
||||||
) : HomeRowLoadingState
|
) : HomeRowLoadingState
|
||||||
|
|
||||||
data class Error(
|
data class Error(
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@ package com.github.damontecres.wholphin.util.profile
|
||||||
|
|
||||||
// Adapted from https://github.com/jellyfin/jellyfin-androidtv/blob/v0.19.4/app/src/main/java/org/jellyfin/androidtv/util/profile/deviceProfile.kt
|
// Adapted from https://github.com/jellyfin/jellyfin-androidtv/blob/v0.19.4/app/src/main/java/org/jellyfin/androidtv/util/profile/deviceProfile.kt
|
||||||
|
|
||||||
|
import android.media.MediaCodecInfo
|
||||||
import androidx.media3.common.MimeTypes
|
import androidx.media3.common.MimeTypes
|
||||||
|
import com.github.damontecres.wholphin.util.profile.KnownDefects.supportsHi10P52
|
||||||
import org.jellyfin.sdk.model.api.CodecType
|
import org.jellyfin.sdk.model.api.CodecType
|
||||||
import org.jellyfin.sdk.model.api.DlnaProfileType
|
import org.jellyfin.sdk.model.api.DlnaProfileType
|
||||||
import org.jellyfin.sdk.model.api.EncodingContext
|
import org.jellyfin.sdk.model.api.EncodingContext
|
||||||
|
|
@ -92,9 +94,14 @@ fun createDeviceProfile(
|
||||||
val hevcMainLevel = mediaTest.getHevcMainLevel()
|
val hevcMainLevel = mediaTest.getHevcMainLevel()
|
||||||
val hevcMain10Level = mediaTest.getHevcMain10Level()
|
val hevcMain10Level = mediaTest.getHevcMain10Level()
|
||||||
val supportsAVC = mediaTest.supportsAVC()
|
val supportsAVC = mediaTest.supportsAVC()
|
||||||
val supportsAVCHigh10 = mediaTest.supportsAVCHigh10()
|
val supportsAVCHigh10 = mediaTest.supportsAVCHigh10() || supportsHi10P52
|
||||||
val avcMainLevel = mediaTest.getAVCMainLevel()
|
val avcMainLevel = mediaTest.getAVCMainLevel()
|
||||||
val avcHigh10Level = mediaTest.getAVCHigh10Level()
|
val avcHigh10Level =
|
||||||
|
if (supportsHi10P52) {
|
||||||
|
MediaCodecInfo.CodecProfileLevel.AVCLevel52
|
||||||
|
} else {
|
||||||
|
mediaTest.getAVCHigh10Level()
|
||||||
|
}
|
||||||
val supportsAV1 = mediaTest.supportsAV1()
|
val supportsAV1 = mediaTest.supportsAV1()
|
||||||
val supportsAV1Main10 = mediaTest.supportsAV1Main10()
|
val supportsAV1Main10 = mediaTest.supportsAV1Main10()
|
||||||
val supportsVC1 = mediaTest.supportsVc1()
|
val supportsVC1 = mediaTest.supportsVc1()
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ package com.github.damontecres.wholphin.util.profile
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List of devie models with known HEVC DoVi/HDR10+ playback issues.
|
* List of device models with known HEVC DoVi/HDR10+ playback issues.
|
||||||
*/
|
*/
|
||||||
private val modelsWithDoViHdr10PlusBug =
|
private val modelsWithDoViHdr10PlusBug =
|
||||||
listOf(
|
listOf(
|
||||||
|
|
@ -14,6 +14,21 @@ private val modelsWithDoViHdr10PlusBug =
|
||||||
"AFTKM", // Amazon Fire TV 4K (2nd Gen)
|
"AFTKM", // Amazon Fire TV 4K (2nd Gen)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of device models that support H264 Hi10P 5.2, but don't advertise it
|
||||||
|
*
|
||||||
|
* Amazon devices from https://developer.amazon.com/docs/device-specs/device-specifications-fire-tv-streaming-media-player.html
|
||||||
|
*/
|
||||||
|
private val modelsWithHi10P52Support =
|
||||||
|
listOf(
|
||||||
|
"AFTMA08C15", // Fire TV Stick 4K Plus (2025)
|
||||||
|
"AFTKRT", // Amazon Fire TV 4K Max (2nd Gen)
|
||||||
|
"AFTKA", // Amazon Fire TV 4K Max (1st Gen)
|
||||||
|
"AFTKM", // Amazon Fire TV 4K (2nd Gen)
|
||||||
|
"AFTMM", // Fire TV Stick 4K - 1st Gen (2018)
|
||||||
|
)
|
||||||
|
|
||||||
object KnownDefects {
|
object KnownDefects {
|
||||||
val hevcDoviHdr10PlusBug = Build.MODEL in modelsWithDoViHdr10PlusBug
|
val hevcDoviHdr10PlusBug = Build.MODEL in modelsWithDoViHdr10PlusBug
|
||||||
|
val supportsHi10P52 = Build.MODEL in modelsWithHi10P52Support
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -475,4 +475,40 @@
|
||||||
<string name="slideshow_duration">Trvání prezentace</string>
|
<string name="slideshow_duration">Trvání prezentace</string>
|
||||||
<string name="play_videos_during_slideshow">Přehrávat videa během prezentace</string>
|
<string name="play_videos_during_slideshow">Přehrávat videa během prezentace</string>
|
||||||
<string name="send_media_info_log_to_server">Odesílat protokol informací o médiích serveru</string>
|
<string name="send_media_info_log_to_server">Odesílat protokol informací o médiích serveru</string>
|
||||||
|
<plurals name="days">
|
||||||
|
<item quantity="one">%s den</item>
|
||||||
|
<item quantity="few">%s dny</item>
|
||||||
|
<item quantity="many">%s dní</item>
|
||||||
|
<item quantity="other">%s dní</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="no_limit">Bez omezení</string>
|
||||||
|
<string name="max_days_next_up">Max. počet dnů v Dalších v pořadí</string>
|
||||||
|
<string name="quick_connect">Rychlé připojení</string>
|
||||||
|
<string name="quick_connect_summary">Autorizovat jiné zařízení k přihlášení k vašemu účtu</string>
|
||||||
|
<string name="quick_connect_code">Zadejte kód Rychlého připojení</string>
|
||||||
|
<string name="quick_connect_code_error">Kód musí mít 6 číslic</string>
|
||||||
|
<string name="quick_connect_success">Zařízení úspěšně autorizováno</string>
|
||||||
|
<string name="add_row">Přidat řádek</string>
|
||||||
|
<string name="genres_in">Žánry v %1$s</string>
|
||||||
|
<string name="recently_released_in">Nedávno vydáno v %1$s</string>
|
||||||
|
<string name="height">Výška</string>
|
||||||
|
<string name="apply_all_rows">Použít na všechny řádky</string>
|
||||||
|
<string name="customize_home">Přizpůsobit domovskou stránku</string>
|
||||||
|
<string name="home_rows">Řádky na domovské stránce</string>
|
||||||
|
<string name="load_from_server">Načíst ze serveru</string>
|
||||||
|
<string name="save_to_server">Uložit na server</string>
|
||||||
|
<string name="load_from_web_client">Načíst z webového klienta</string>
|
||||||
|
<string name="use_series">Použít obrázek seriálu</string>
|
||||||
|
<string name="add_row_for">Přidat řádek pro %1$s</string>
|
||||||
|
<string name="overwrite_server_settings">Přepsat nastavení na serveru?</string>
|
||||||
|
<string name="overwrite_local_settings">Přepsat místní nastavení?</string>
|
||||||
|
<string name="for_episodes">Pro epizody</string>
|
||||||
|
<string name="suggestions_for">Návrhy na %1$s</string>
|
||||||
|
<string name="increase_all_cards_size">Zvětšit velikost všech karet</string>
|
||||||
|
<string name="decrease_all_cards_size">Zmenšit velikost všech karet</string>
|
||||||
|
<string name="use_thumb_images">Použít obrázky miniatur</string>
|
||||||
|
<string name="settings_saved">Nastavení uložena</string>
|
||||||
|
<string name="display_presets">Zobrazit předvolby</string>
|
||||||
|
<string name="display_presets_description">Vestavěné předvolby pro rychlou úpravu všech řádků</string>
|
||||||
|
<string name="customize_home_summary">Vyberte řádky a obrázky na domovské stránce</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
307
app/src/main/res/values-da/strings.xml
Normal file
307
app/src/main/res/values-da/strings.xml
Normal file
|
|
@ -0,0 +1,307 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="about">Om</string>
|
||||||
|
<string name="active_recordings">Aktive optagelser</string>
|
||||||
|
<string name="add_favorite">Favorit</string>
|
||||||
|
<string name="add_server">Tilføj server</string>
|
||||||
|
<string name="add_user">Tilføj bruger</string>
|
||||||
|
<string name="audio">Lyd</string>
|
||||||
|
<string name="birthplace">Fødested</string>
|
||||||
|
<string name="bitrate">Bitrate</string>
|
||||||
|
<string name="born">Født</string>
|
||||||
|
<string name="cancel_recording">Annullér optagelse</string>
|
||||||
|
<string name="cancel_series_recording">Annullér serieoptagelse</string>
|
||||||
|
<string name="cancel">Annullér</string>
|
||||||
|
<string name="chapters">Kapitler</string>
|
||||||
|
<string name="choose_stream">Vælg %1$s</string>
|
||||||
|
<string name="clear_image_cache">Ryd billedcache</string>
|
||||||
|
<string name="collection">Samling</string>
|
||||||
|
<string name="collections">Samlinger</string>
|
||||||
|
<string name="community_rating">Brugerbedømmelse</string>
|
||||||
|
<string name="compose_error_message_title">Der opstod en fejl! Tryk på knappen for at sende logfiler til din server.</string>
|
||||||
|
<string name="confirm">Bekræft</string>
|
||||||
|
<string name="continue_watching">Fortsæt med at se</string>
|
||||||
|
<string name="critic_rating">Kritikerbedømmelse</string>
|
||||||
|
<string name="decimal_seconds">%.2f sekunder</string>
|
||||||
|
<string name="default_track">Standard</string>
|
||||||
|
<string name="delete">Slet</string>
|
||||||
|
<string name="died">Død</string>
|
||||||
|
<string name="directed_by">Instrueret af %1$s</string>
|
||||||
|
<string name="director">Instruktør</string>
|
||||||
|
<string name="disabled">Deaktiveret</string>
|
||||||
|
<string name="discovered_servers">Opdagede servere</string>
|
||||||
|
<string name="download_and_update"><![CDATA[Download og opdater]]></string>
|
||||||
|
<string name="downloading">Downloader…</string>
|
||||||
|
<string name="enabled">Aktiveret</string>
|
||||||
|
<string name="ends_at">Slutter %1$s</string>
|
||||||
|
<string name="enter_server_url">Indtast server-IP eller URL</string>
|
||||||
|
<string name="enter_server_address">Indtast serveradresse</string>
|
||||||
|
<string name="episodes">Episoder</string>
|
||||||
|
<string name="error_loading_collection">Fejl ved indlæsning af samling %1$s</string>
|
||||||
|
<string name="external_track">Ekstern</string>
|
||||||
|
<string name="favorites">Favoritter</string>
|
||||||
|
<string name="file_size">Størrelse</string>
|
||||||
|
<string name="forced_track">Tvunget</string>
|
||||||
|
<string name="genres">Genrer</string>
|
||||||
|
<string name="go_to_series">Gå til serien</string>
|
||||||
|
<string name="go_to">Gå til</string>
|
||||||
|
<string name="hide_controller_timeout">Skjul afspilningsknapper</string>
|
||||||
|
<string name="hide_debug_info">Skjul fejlfindingsoplysninger</string>
|
||||||
|
<string name="hide">Skjul</string>
|
||||||
|
<string name="home">Hjem</string>
|
||||||
|
<string name="immediate">Umiddelbar</string>
|
||||||
|
<string name="intro">Intro</string>
|
||||||
|
<string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZÆØÅ</string>
|
||||||
|
<string name="library">Bibliotek</string>
|
||||||
|
<string name="license_info">Licensoplysninger</string>
|
||||||
|
<string name="live_tv">Live-tv</string>
|
||||||
|
<string name="loading">Indlæser…</string>
|
||||||
|
<string name="mark_entire_series_as_played">Markér hele serien som afspillet?</string>
|
||||||
|
<string name="mark_entire_series_as_unplayed">Markér hele serien som ikke afspillet?</string>
|
||||||
|
<string name="mark_unwatched">Markér som uset</string>
|
||||||
|
<string name="mark_watched">Markér som set</string>
|
||||||
|
<string name="max_bitrate">Maks. bitrate</string>
|
||||||
|
<string name="more_like_this">Mere som dette</string>
|
||||||
|
<string name="more">Mere</string>
|
||||||
|
<plurals name="movies">
|
||||||
|
<item quantity="one">Film</item>
|
||||||
|
<item quantity="other"/>
|
||||||
|
</plurals>
|
||||||
|
<string name="name">Navn</string>
|
||||||
|
<string name="next_up">Næste</string>
|
||||||
|
<string name="no_data">Ingen data</string>
|
||||||
|
<string name="no_results">Ingen resultater</string>
|
||||||
|
<string name="no_servers_found">Ingen servere fundet</string>
|
||||||
|
<string name="no_scheduled_recordings">Ingen planlagte optagelser</string>
|
||||||
|
<string name="no_update_available">Ingen opdatering tilgængelig</string>
|
||||||
|
<string name="none">Ingen</string>
|
||||||
|
<string name="only_forced_subtitles">Kun tvungne undertekster</string>
|
||||||
|
<string name="outro">Outro</string>
|
||||||
|
<string name="path">Filsti</string>
|
||||||
|
<plurals name="people">
|
||||||
|
<item quantity="one">Personer</item>
|
||||||
|
<item quantity="other"/>
|
||||||
|
</plurals>
|
||||||
|
<string name="play_count">Antal afspilninger</string>
|
||||||
|
<string name="play_from_here">Afspil herfra</string>
|
||||||
|
<string name="play">Afspil</string>
|
||||||
|
<string name="playback_debug_info">Vis debugoplysninger for afspilning</string>
|
||||||
|
<string name="playback_speed">Afspilningshastighed</string>
|
||||||
|
<string name="playback">Afspilning</string>
|
||||||
|
<string name="playlist">Playlist</string>
|
||||||
|
<string name="playlists">Playlists</string>
|
||||||
|
<string name="preview">Forhåndsvisning</string>
|
||||||
|
<string name="profile_specific_settings">Profilindstillinger</string>
|
||||||
|
<string name="queue">Kø</string>
|
||||||
|
<string name="recap">Opsummering</string>
|
||||||
|
<string name="recently_added_in">Nyligt tilføjet i %1$s</string>
|
||||||
|
<string name="recently_added">Nyligt tilføjet</string>
|
||||||
|
<string name="recently_recorded">Nyligt optaget</string>
|
||||||
|
<string name="recently_released">Nyligt udkommet</string>
|
||||||
|
<string name="recommended">Anbefalet</string>
|
||||||
|
<string name="record_program">Optag program</string>
|
||||||
|
<string name="record_series">Optag serie</string>
|
||||||
|
<string name="remove_favorite">Fjern fra favoritter</string>
|
||||||
|
<string name="restart">Genstart</string>
|
||||||
|
<string name="resume">Fortsæt</string>
|
||||||
|
<string name="save">Gem</string>
|
||||||
|
<string name="search_and_download"><![CDATA[Søg og download]]></string>
|
||||||
|
<string name="search">Søg</string>
|
||||||
|
<string name="searching">Søger…</string>
|
||||||
|
<string name="voice_search">Stemmesøgning</string>
|
||||||
|
<string name="voice_starting">Starter</string>
|
||||||
|
<string name="voice_search_prompt">Tal for at søge</string>
|
||||||
|
<string name="processing">Arbejder</string>
|
||||||
|
<string name="press_back_to_cancel">Tryk tilbage for at afbryde</string>
|
||||||
|
<string name="voice_error_audio">Fejl ved lydoptagelse</string>
|
||||||
|
<string name="voice_error_client">Fejl ved stemmegenkendelse</string>
|
||||||
|
<string name="voice_error_permissions">Mikrofontilladelse kræves</string>
|
||||||
|
<string name="voice_error_network">Netværksfejl</string>
|
||||||
|
<string name="voice_error_network_timeout">Netværkstimeout</string>
|
||||||
|
<string name="voice_error_no_match">Ingen tale genkendt</string>
|
||||||
|
<string name="voice_error_busy">Stemmegenkendelse optaget</string>
|
||||||
|
<string name="voice_error_server">Serverfejl</string>
|
||||||
|
<string name="voice_error_speech_timeout">Ingen tale registreret</string>
|
||||||
|
<string name="voice_error_unknown">Ukendt fejl</string>
|
||||||
|
<string name="voice_error_start_failed">Kunne ikke starte stemmegenkendelse</string>
|
||||||
|
<string name="voice_error_timeout">Tidsgrænsen for stemmegenkendelse er overskredet</string>
|
||||||
|
<string name="retry">Prøv igen</string>
|
||||||
|
<string name="select_server">Vælg server</string>
|
||||||
|
<string name="select_user">Vælg bruger</string>
|
||||||
|
<string name="show_debug_info">Vis fejlfindingsoplysninger</string>
|
||||||
|
<string name="show_next_up_when">Vis næste</string>
|
||||||
|
<string name="show">Vis</string>
|
||||||
|
<string name="shuffle">Bland</string>
|
||||||
|
<string name="skip">Spring over</string>
|
||||||
|
<string name="sort_by_date_added">Dato tilføjet</string>
|
||||||
|
<string name="sort_by_date_episode_added">Dato episode tilføjet</string>
|
||||||
|
<string name="sort_by_date_played">Dato afspillet</string>
|
||||||
|
<string name="sort_by_date_released">Udgivelsesdato</string>
|
||||||
|
<string name="sort_by_name">Navn</string>
|
||||||
|
<string name="sort_by_random">Tilfældig</string>
|
||||||
|
<string name="studios">Studier</string>
|
||||||
|
<string name="submit">Indsend</string>
|
||||||
|
<string name="subtitle_download_too_long">Downloaden tager lang tid, du skal muligvis genstarte afspilningen</string>
|
||||||
|
<string name="subtitle">Undertekst</string>
|
||||||
|
<string name="subtitles">Undertekster</string>
|
||||||
|
<string name="suggestions">Forslag</string>
|
||||||
|
<string name="switch_servers">Skift servere</string>
|
||||||
|
<string name="switch_user">Skift</string>
|
||||||
|
<string name="top_unwatched">Bedst bedømte usete</string>
|
||||||
|
<string name="trailer">Trailer</string>
|
||||||
|
<plurals name="trailers">
|
||||||
|
<item quantity="one">Trailere</item>
|
||||||
|
<item quantity="other"></item>
|
||||||
|
</plurals>
|
||||||
|
<string name="tv_dvr_schedule">DVR-plan</string>
|
||||||
|
<string name="tv_guide">TV-guide</string>
|
||||||
|
<string name="tv_season">Sæson</string>
|
||||||
|
<string name="tv_seasons">Sæsoner</string>
|
||||||
|
<plurals name="tv_shows">
|
||||||
|
<item quantity="one">TV-serier</item>
|
||||||
|
<item quantity="other"></item>
|
||||||
|
</plurals>
|
||||||
|
<string name="ui_interface">Grænseflade</string>
|
||||||
|
<string name="unknown">Ukendt</string>
|
||||||
|
<string name="updates">Opdateringer</string>
|
||||||
|
<string name="version">Version</string>
|
||||||
|
<string name="video_scale">Videoskalering</string>
|
||||||
|
<string name="video">Video</string>
|
||||||
|
<string name="videos">Videoer</string>
|
||||||
|
<string name="watch_live">Se direkte</string>
|
||||||
|
<string name="years_old">%1$d år gammel</string>
|
||||||
|
<string name="play_with_transcoding">Afspil med transkodning</string>
|
||||||
|
<string name="media_information">Medieinformation</string>
|
||||||
|
<string name="show_clock">Vis ur</string>
|
||||||
|
<string name="aired_episode_order">Udsendelsesrækkefølge</string>
|
||||||
|
<string name="create_playlist">Opret ny playliste</string>
|
||||||
|
<string name="add_to_playlist">Føj til playliste</string>
|
||||||
|
<string name="one_click_pause">Pause med ét klik</string>
|
||||||
|
<string name="one_click_pause_summary_on">Tryk på midten af D-Pad\'en for at pause/afspille</string>
|
||||||
|
<string name="italic_font">Kursiv skrift</string>
|
||||||
|
<string name="font">Skrifttype</string>
|
||||||
|
<string name="background">Baggrund</string>
|
||||||
|
<string name="success">Succes</string>
|
||||||
|
<string name="official_rating">Aldersgrænse</string>
|
||||||
|
<string name="runtime_sort">Spilletid</string>
|
||||||
|
<string name="extras">Ekstra</string>
|
||||||
|
<plurals name="other_extras">
|
||||||
|
<item quantity="one">Andet</item>
|
||||||
|
<item quantity="other"></item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="behind_the_scenes">
|
||||||
|
<item quantity="one">Bag kulisserne</item>
|
||||||
|
<item quantity="other"></item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="theme_songs">
|
||||||
|
<item quantity="one">Temasange</item>
|
||||||
|
<item quantity="other"></item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="theme_videos">
|
||||||
|
<item quantity="one">Temavideoer</item>
|
||||||
|
<item quantity="other"></item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="clips">
|
||||||
|
<item quantity="one">Klip</item>
|
||||||
|
<item quantity="other"></item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="deleted_scenes">
|
||||||
|
<item quantity="one">Slettede scener</item>
|
||||||
|
<item quantity="other"></item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="interviews">
|
||||||
|
<item quantity="one">Interview</item>
|
||||||
|
<item quantity="other"></item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="scenes">
|
||||||
|
<item quantity="one">Scener</item>
|
||||||
|
<item quantity="other"></item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="samples">
|
||||||
|
<item quantity="one">Prøver</item>
|
||||||
|
<item quantity="other"></item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="featurettes">
|
||||||
|
<item quantity="one">Specialindslag</item>
|
||||||
|
<item quantity="other"></item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="shorts">
|
||||||
|
<item quantity="one">Kortfilm</item>
|
||||||
|
<item quantity="other"></item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="downloads">
|
||||||
|
<item quantity="one">%s download</item>
|
||||||
|
<item quantity="other">%s downloads</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="hours">
|
||||||
|
<item quantity="one">%d time</item>
|
||||||
|
<item quantity="other">%d timer</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="days">
|
||||||
|
<item quantity="one">%s dag</item>
|
||||||
|
<item quantity="other">%s dage</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="items">
|
||||||
|
<item quantity="one">%d element</item>
|
||||||
|
<item quantity="other">%d elementer</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="seconds">
|
||||||
|
<item quantity="one">%d sekund</item>
|
||||||
|
<item quantity="other">%d sekunder</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="ac3_supported">Enheden understøtter AC3/Dolby Digital</string>
|
||||||
|
<string name="advanced_settings">Avancerede indstillinger</string>
|
||||||
|
<string name="advanced_ui">Avanceret brugergrænseflade</string>
|
||||||
|
<string name="app_theme">Tema</string>
|
||||||
|
<string name="auto_check_for_updates">Søg automatisk efter opdateringer</string>
|
||||||
|
<string name="auto_play_next_delay">Forsinkelse før næste afspilning</string>
|
||||||
|
<string name="auto_play_next">Afspil næste automatisk</string>
|
||||||
|
<string name="check_for_updates">Søg efter opdateringer</string>
|
||||||
|
<string name="combine_continue_next_summary">Gælder kun for tv-serier</string>
|
||||||
|
<string name="combine_continue_next"><![CDATA[Kombiner Fortsæt med at se & Næste]]></string>
|
||||||
|
<string name="direct_play_ass">Direkte afspilning af ASS-undertekster</string>
|
||||||
|
<string name="direct_play_pgs">Direkte afspilning af PGS-undertekster</string>
|
||||||
|
<string name="downmix_stereo">Mix altid ned til stereo</string>
|
||||||
|
<string name="ffmpeg_extension_pref">Brug FFmpeg-dekodermodulet</string>
|
||||||
|
<string name="global_content_scale">Standard skalering</string>
|
||||||
|
<string name="install_update">Installer opdatering</string>
|
||||||
|
<string name="installed_version">Installeret version</string>
|
||||||
|
<string name="max_homepage_items">Maks antal elementer per række på startsiden</string>
|
||||||
|
<string name="nav_drawer_pins_summary">Vælg de standardelementer, der skal vises, andre vil blive skjult</string>
|
||||||
|
<string name="nav_drawer_pins">Tilpas elementer i navigationsmenuen</string>
|
||||||
|
<string name="nav_drawer_switch_on_focus_summary_off">Klik for at skifte side</string>
|
||||||
|
<string name="nav_drawer_switch_on_focus">Skift sider i navigationsmenuen ved fokus</string>
|
||||||
|
<string name="pass_out_protection">Inaktivitetsbeskyttelse</string>
|
||||||
|
<string name="play_theme_music">Afspil temamusik</string>
|
||||||
|
<string name="playback_overrides">Tilsidesættelser af afspilning</string>
|
||||||
|
<string name="remember_selected_tab">Husk valgte faner</string>
|
||||||
|
<string name="rewatch_next_up">Tillad gensening i \"Næste\"</string>
|
||||||
|
<string name="seek_bar_steps">Trinlængde for søgelinje</string>
|
||||||
|
<string name="send_app_logs_summary">Nyttig til fejlfinding</string>
|
||||||
|
<string name="send_app_logs">Send app-logfiler til den aktuelle server</string>
|
||||||
|
<string name="send_crash_reports_summary">Vil forsøge at sende til den sidst forbundne server</string>
|
||||||
|
<string name="send_crash_reports">Send nedbrudsrapporter</string>
|
||||||
|
<string name="settings">Indstillinger</string>
|
||||||
|
<string name="skip_back_on_resume_preference">Spring tilbage, når afspilningen genoptages</string>
|
||||||
|
<string name="skip_back_preference">Spring tilbage</string>
|
||||||
|
<string name="skip_commercials_behavior">Adfærd ved at springe reklamer over</string>
|
||||||
|
<string name="skip_forward_preference">Spring fremad</string>
|
||||||
|
<string name="skip_intro_behavior">Adfærd ved at springe intro over</string>
|
||||||
|
<string name="skip_outro_behavior">Adfærd ved at springe outro over</string>
|
||||||
|
<string name="skip_previews_behavior">Adfærd ved at springe forhåndsvisninger over</string>
|
||||||
|
<string name="skip_recap_behavior">Adfærd ved at springe opsummeringer over</string>
|
||||||
|
<string name="update_available">Opdatering tilgængelig</string>
|
||||||
|
<string name="update_url_summary">URL brugt til at søge efter appopdateringer</string>
|
||||||
|
<string name="update_url">URL til opdatering</string>
|
||||||
|
<string name="font_size">Tekststørrelse</string>
|
||||||
|
<string name="font_color">Tekstfarve</string>
|
||||||
|
<string name="font_opacity">Tekstgennemsigtighed</string>
|
||||||
|
<string name="bold_font">Fed skrift</string>
|
||||||
|
<string name="edge_style">Kantstil</string>
|
||||||
|
<string name="edge_color">Kantfarve</string>
|
||||||
|
<string name="background_opacity">Baggrundsopacitet</string>
|
||||||
|
<string name="background_style">Baggrundsstil</string>
|
||||||
|
<string name="subtitle_style">Undertekststil</string>
|
||||||
|
<string name="background_color">Baggrundsfarve</string>
|
||||||
|
<string name="reset">Nulstil</string>
|
||||||
|
</resources>
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
<string name="confirm">Bestätigen</string>
|
<string name="confirm">Bestätigen</string>
|
||||||
<string name="continue_watching">Weiterschauen</string>
|
<string name="continue_watching">Weiterschauen</string>
|
||||||
<string name="critic_rating">Kritiken</string>
|
<string name="critic_rating">Kritiken</string>
|
||||||
<string name="decimal_seconds">%.1f Sekunden</string>
|
<string name="decimal_seconds">%.2f Sekunden</string>
|
||||||
<string name="default_track">Standard</string>
|
<string name="default_track">Standard</string>
|
||||||
<string name="delete">Löschen</string>
|
<string name="delete">Löschen</string>
|
||||||
<string name="died">Gestorben</string>
|
<string name="died">Gestorben</string>
|
||||||
|
|
@ -321,7 +321,7 @@
|
||||||
<string name="no">Nein</string>
|
<string name="no">Nein</string>
|
||||||
<string name="show_titles">Titel anzeigen</string>
|
<string name="show_titles">Titel anzeigen</string>
|
||||||
<string name="live_tv_repeat">Wiederholen</string>
|
<string name="live_tv_repeat">Wiederholen</string>
|
||||||
<string name="general">Generell</string>
|
<string name="general">Allgemein</string>
|
||||||
<string name="no_trailers">Keine Trailer</string>
|
<string name="no_trailers">Keine Trailer</string>
|
||||||
<string name="play_trailer">Trailer abspielen</string>
|
<string name="play_trailer">Trailer abspielen</string>
|
||||||
<string name="local">Lokal</string>
|
<string name="local">Lokal</string>
|
||||||
|
|
@ -417,4 +417,19 @@
|
||||||
<string name="stop_slideshow">Diashow anhalten</string>
|
<string name="stop_slideshow">Diashow anhalten</string>
|
||||||
<string name="brightness">Helligkeit</string>
|
<string name="brightness">Helligkeit</string>
|
||||||
<string name="contrast">Kontrast</string>
|
<string name="contrast">Kontrast</string>
|
||||||
|
<string name="zoom_out">Verkleinern</string>
|
||||||
|
<string name="zoom_in">Vergrößern</string>
|
||||||
|
<string name="quick_connect_success">Gerät erfolgreich authorisiert</string>
|
||||||
|
<string name="saturation">Sättigung</string>
|
||||||
|
<string name="hue">Farbton</string>
|
||||||
|
<plurals name="days">
|
||||||
|
<item quantity="one">%s Tag</item>
|
||||||
|
<item quantity="other">%s Tage</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="quick_connect_code_error">Der Code muss aus 6 Ziffern bestehen</string>
|
||||||
|
<string name="hdr_subtitle_style">HDR-Untertitelstil</string>
|
||||||
|
<string name="slideshow_duration">Dauer der Diashow</string>
|
||||||
|
<string name="play_videos_during_slideshow">Videos während der Diashow abspielen</string>
|
||||||
|
<string name="max_days_next_up">Maximale Tage in \"Als nächstes\"</string>
|
||||||
|
<string name="quick_connect">Quick Connect</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
<string name="commercial">Comercial</string>
|
<string name="commercial">Comercial</string>
|
||||||
<string name="confirm">Confirmar</string>
|
<string name="confirm">Confirmar</string>
|
||||||
<string name="continue_watching">Continuar viendo</string>
|
<string name="continue_watching">Continuar viendo</string>
|
||||||
<string name="decimal_seconds">%.1f segundos</string>
|
<string name="decimal_seconds">%.2f segundos</string>
|
||||||
<string name="delete">Eliminar</string>
|
<string name="delete">Eliminar</string>
|
||||||
<string name="died">Fallecido</string>
|
<string name="died">Fallecido</string>
|
||||||
<string name="enabled">Activado</string>
|
<string name="enabled">Activado</string>
|
||||||
|
|
@ -181,7 +181,7 @@
|
||||||
<plurals name="clips">
|
<plurals name="clips">
|
||||||
<item quantity="one">Clip</item>
|
<item quantity="one">Clip</item>
|
||||||
<item quantity="many">Clips</item>
|
<item quantity="many">Clips</item>
|
||||||
<item quantity="other"/>
|
<item quantity="other">Clips</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="deleted_scenes">
|
<plurals name="deleted_scenes">
|
||||||
<item quantity="one">Escena eliminada</item>
|
<item quantity="one">Escena eliminada</item>
|
||||||
|
|
@ -422,4 +422,61 @@
|
||||||
<string name="request_4k">Solicitar en 4K</string>
|
<string name="request_4k">Solicitar en 4K</string>
|
||||||
<string name="software_decoding_av1">Decodificación por software AV1</string>
|
<string name="software_decoding_av1">Decodificación por software AV1</string>
|
||||||
<string name="upgrade_mpv_toast">MPV es ahora el reproductor por defecto para HDR.\nPuedes cambiar esto en los ajustes.</string>
|
<string name="upgrade_mpv_toast">MPV es ahora el reproductor por defecto para HDR.\nPuedes cambiar esto en los ajustes.</string>
|
||||||
|
<plurals name="days">
|
||||||
|
<item quantity="one">%s día</item>
|
||||||
|
<item quantity="many">%s días</item>
|
||||||
|
<item quantity="other">%s días</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="quick_connect">Conexión Rápida</string>
|
||||||
|
<string name="quick_connect_summary">Autoriza a otro dispositivo para acceder a tu cuenta</string>
|
||||||
|
<string name="quick_connect_code">Introduce el código de Conexión Rápida</string>
|
||||||
|
<string name="quick_connect_code_error">El código debe tener 6 dígitos</string>
|
||||||
|
<string name="quick_connect_success">Dispositivo autorizado con éxito</string>
|
||||||
|
<string name="hdr_subtitle_style">Estilo de subtítulos HDR</string>
|
||||||
|
<string name="image_subtitle_opacity">Opacidad de los subtítulos</string>
|
||||||
|
<string name="brightness">Brillo</string>
|
||||||
|
<string name="contrast">Contraste</string>
|
||||||
|
<string name="saturation">Saturación</string>
|
||||||
|
<string name="hue">Tono de color</string>
|
||||||
|
<string name="red">Rojo</string>
|
||||||
|
<string name="green">Verde</string>
|
||||||
|
<string name="blue">Azul</string>
|
||||||
|
<string name="blur">Desenfoque</string>
|
||||||
|
<string name="save_for_album">Guardar en el álbum</string>
|
||||||
|
<string name="play_slideshow">Reproducir presentación</string>
|
||||||
|
<string name="stop_slideshow">Detener presentación</string>
|
||||||
|
<string name="slideshow_at_beginning">Presentación inicial</string>
|
||||||
|
<string name="no_more_images">No hay más imágenes</string>
|
||||||
|
<string name="rotate_left">Girar a la izquierda</string>
|
||||||
|
<string name="rotate_right">Girar a la derecha</string>
|
||||||
|
<string name="zoom_in">Acercar</string>
|
||||||
|
<string name="zoom_out">Alejar</string>
|
||||||
|
<string name="slideshow_duration">Duración de la presentación</string>
|
||||||
|
<string name="play_videos_during_slideshow">Reproducir vídeos durante la presentación</string>
|
||||||
|
<string name="send_media_info_log_to_server">Enviar registro de medios al servidor</string>
|
||||||
|
<string name="no_limit">Sin límite</string>
|
||||||
|
<string name="max_days_next_up">Límite de días en \"Siguiente\"</string>
|
||||||
|
<string name="add_row">Añadir fila</string>
|
||||||
|
<string name="genres_in">Géneros de %1$s</string>
|
||||||
|
<string name="height">Altura</string>
|
||||||
|
<string name="apply_all_rows">Aplicar a todas las filas</string>
|
||||||
|
<string name="customize_home">Personalizar página de inicio</string>
|
||||||
|
<string name="home_rows">Filas de inicio</string>
|
||||||
|
<string name="load_from_server">Cargar desde el servidor</string>
|
||||||
|
<string name="save_to_server">Guardar en el servidor</string>
|
||||||
|
<string name="load_from_web_client">Cargar del cliente web</string>
|
||||||
|
<string name="use_series">Usar imágenes de la serie</string>
|
||||||
|
<string name="add_row_for">Agregar fila para %1$s</string>
|
||||||
|
<string name="overwrite_server_settings">¿Sobrescribir ajustes en el servidor?</string>
|
||||||
|
<string name="overwrite_local_settings">¿Sobrescribir ajustes locales?</string>
|
||||||
|
<string name="for_episodes">Para episodios</string>
|
||||||
|
<string name="suggestions_for">Sugerencias para %1$s</string>
|
||||||
|
<string name="increase_all_cards_size">Aumentar tamaño de todas las tarjetas</string>
|
||||||
|
<string name="decrease_all_cards_size">Reducir tamaño de todas las tarjetas</string>
|
||||||
|
<string name="use_thumb_images">Usar miniaturas</string>
|
||||||
|
<string name="settings_saved">Ajustes guardados</string>
|
||||||
|
<string name="display_presets">Preajustes de visualización</string>
|
||||||
|
<string name="display_presets_description">Preajustes integrados para estilizar todas las filas</string>
|
||||||
|
<string name="customize_home_summary">Elegir filas e imágenes en la página de inicio</string>
|
||||||
|
<string name="recently_released_in">Lanzado recientemente en %1$s</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue