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
|
||||
run: |
|
||||
./gradlew clean assembleDebug testDebugUnitTest --no-daemon
|
||||
apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//')
|
||||
apks=$(find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | tr '\0' ',' | sed 's/,$//')
|
||||
echo "apks=$apks" >> "$GITHUB_OUTPUT"
|
||||
- name: Tar build dirs
|
||||
|
||||
test-patch:
|
||||
runs-on: ubuntu-latest
|
||||
needs: pre-commit
|
||||
steps:
|
||||
- name: Checkout the code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Test applying patch
|
||||
run: |
|
||||
tar -czf build.tgz ./app/.
|
||||
- uses: actions/upload-artifact@v6
|
||||
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
|
||||
git apply app/src/patches/play_store.patch
|
||||
git diff
|
||||
|
|
|
|||
30
.github/workflows/release.yml
vendored
30
.github/workflows/release.yml
vendored
|
|
@ -39,11 +39,24 @@ jobs:
|
|||
SIGNING_KEY: "${{ secrets.SIGNING_KEY }}"
|
||||
run: |
|
||||
./gradlew clean assembleRelease --no-daemon
|
||||
|
||||
- name: Build app
|
||||
id: buildaab
|
||||
env:
|
||||
KEY_ALIAS: "${{ secrets.KEY_ALIAS }}"
|
||||
KEY_PASSWORD: "${{ secrets.KEY_PASSWORD }}"
|
||||
KEY_STORE_PASSWORD: "${{ secrets.KEY_STORE_PASSWORD }}"
|
||||
SIGNING_KEY: "${{ secrets.SIGNING_KEY }}"
|
||||
run: |
|
||||
git apply app/src/patches/play_store.patch
|
||||
./gradlew bundleRelease --no-daemon
|
||||
aab=$(find app/build/outputs -name '*.aab')
|
||||
echo "aab=$aab" >> "$GITHUB_OUTPUT"
|
||||
- name: Verify signatures
|
||||
run: |
|
||||
echo "Verify APK signatures"
|
||||
find app/build/outputs/apk -name '*.apk'
|
||||
find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 -n1 ${{env.ANDROID_SDK_ROOT}}/build-tools/${{ env.BUILD_TOOLS_VERSION }}/apksigner verify --verbose --print-certs
|
||||
echo "Verify APK/AAB signatures"
|
||||
find app/build/outputs \( -name '*.apk' -or -name '*.aab' \)
|
||||
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
|
||||
id: apks
|
||||
run: |
|
||||
|
|
@ -59,11 +72,18 @@ jobs:
|
|||
- name: Checksums
|
||||
run: |
|
||||
echo "SHA256 checksums:"
|
||||
find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 sha256sum
|
||||
find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 sha256sum
|
||||
- name: Upload AAB
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: AAB
|
||||
path: |
|
||||
app/build/outputs/bundle/**/*.aab
|
||||
compression-level: 0
|
||||
- name: Create GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release create "${{ env.TAG_NAME }}" \
|
||||
--latest --title "${{ env.TAG_NAME }}" --verify-tag -n "Placeholder" --draft \
|
||||
--latest --title "${{ env.TAG_NAME }}" --verify-tag -n "" --draft \
|
||||
"app/build/outputs/apk/**/*.apk"
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin
|
|||
|
||||
### User interface
|
||||
|
||||
- Customize the home page to see the content you are interested in
|
||||
- Use poster or thumb images, show/hide titles, add/remove/re-order different types of rows!
|
||||
- A navigation drawer for quick access to libraries, favorites, search, and settings from almost anywhere in the app
|
||||
- Integration with [Jellyseerr](https://github.com/seerr-team/seerr) to discover new movies and TV shows
|
||||
- Option to combine Continue Watching & Next Up rows
|
||||
|
|
@ -112,6 +114,10 @@ You can [help translate Wholphin](https://translate.codeberg.org/engage/wholphin
|
|||
|
||||
## Additional screenshots
|
||||
|
||||
### Customized home page
|
||||

|
||||
|
||||
|
||||
### 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" />
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import java.util.Properties
|
|||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.hilt)
|
||||
|
|
@ -48,39 +47,6 @@ android {
|
|||
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 {
|
||||
if (shouldSign) {
|
||||
create("ci") {
|
||||
|
|
@ -98,14 +64,15 @@ android {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
isDebuggable = false
|
||||
if (shouldSign) {
|
||||
signingConfig = signingConfigs.getByName("ci")
|
||||
} else {
|
||||
|
|
@ -120,10 +87,11 @@ android {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug {
|
||||
if (shouldSign) {
|
||||
signingConfig = signingConfigs.getByName("ci")
|
||||
}
|
||||
isMinifyEnabled = false
|
||||
isDebuggable = true
|
||||
applicationIdSuffix = ".debug"
|
||||
}
|
||||
|
||||
applicationVariants.all {
|
||||
|
|
@ -138,6 +106,25 @@ android {
|
|||
}
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3
|
||||
jvmTarget = JvmTarget.JVM_11
|
||||
javaParameters = true
|
||||
}
|
||||
}
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
compose = true
|
||||
}
|
||||
room {
|
||||
schemaDirectory("$projectDir/schemas")
|
||||
}
|
||||
|
||||
splits {
|
||||
abi {
|
||||
|
|
@ -150,7 +137,7 @@ android {
|
|||
|
||||
sourceSets {
|
||||
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
|
||||
lateinit var suggestionsSchedulerService: SuggestionsSchedulerService
|
||||
|
||||
@Inject
|
||||
lateinit var backdropService: BackdropService
|
||||
|
||||
// Note: unused but injected to ensure it is created
|
||||
@Inject
|
||||
lateinit var serverEventListener: ServerEventListener
|
||||
|
|
@ -247,6 +250,9 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
|
||||
is SetupDestination.AppContent -> {
|
||||
LaunchedEffect(Unit) {
|
||||
backdropService.clearBackdrop()
|
||||
}
|
||||
val current = key.current
|
||||
ProvideLocalClock {
|
||||
if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) {
|
||||
|
|
@ -409,7 +415,7 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
|
||||
fun changeDisplayMode(modeId: Int) {
|
||||
lifecycleScope.launch(Dispatchers.Main + ExceptionHandler()) {
|
||||
lifecycleScope.launch(Dispatchers.Main + ExceptionHandler(autoToast = true)) {
|
||||
val attrs = window.attributes
|
||||
if (attrs.preferredDisplayModeId != modeId) {
|
||||
Timber.d("Switch preferredDisplayModeId to %s", modeId)
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ import java.util.UUID
|
|||
SeerrUser::class,
|
||||
|
||||
],
|
||||
version = 30,
|
||||
version = 31,
|
||||
exportSchema = true,
|
||||
autoMigrations = [
|
||||
AutoMigration(3, 4),
|
||||
|
|
@ -54,6 +54,7 @@ import java.util.UUID
|
|||
AutoMigration(11, 12),
|
||||
AutoMigration(12, 20),
|
||||
AutoMigration(20, 30),
|
||||
AutoMigration(30, 31),
|
||||
],
|
||||
)
|
||||
@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 org.jellyfin.sdk.Jellyfin
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.quickConnectApi
|
||||
import org.jellyfin.sdk.api.client.extensions.systemApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||
import org.jellyfin.sdk.model.api.AuthenticationResult
|
||||
|
|
@ -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 {
|
||||
fun getServerSharedPreferences(context: Context): SharedPreferences =
|
||||
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.extensions.ticks
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration
|
||||
|
||||
@Serializable
|
||||
|
|
@ -32,6 +33,8 @@ import kotlin.time.Duration
|
|||
data class BaseItem(
|
||||
val data: BaseItemDto,
|
||||
val useSeriesForPrimary: Boolean,
|
||||
val imageUrlOverride: String? = null,
|
||||
val destinationOverride: Destination? = null,
|
||||
) : CardGridItem {
|
||||
val id get() = data.id
|
||||
|
||||
|
|
@ -165,6 +168,7 @@ data class BaseItem(
|
|||
}?.toIntOrNull()
|
||||
|
||||
fun destination(index: Int? = null): Destination {
|
||||
if (destinationOverride != null) return destinationOverride
|
||||
val result =
|
||||
// Redirect episodes & seasons to their series if possible
|
||||
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 -> {
|
||||
Destination.MediaItem(this)
|
||||
}
|
||||
|
|
@ -214,3 +237,28 @@ data class BaseItemUi(
|
|||
val episodeUnplayedCornerText: String?,
|
||||
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
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
|
||||
|
|
@ -24,4 +25,5 @@ data class NavDrawerPinnedItem(
|
|||
val userId: Int,
|
||||
val itemId: String,
|
||||
val type: NavPinType,
|
||||
@ColumnInfo(defaultValue = "-1") val order: Int,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -656,6 +656,13 @@ sealed interface AppPreference<Pref, T> {
|
|||
setter = { prefs, _ -> prefs },
|
||||
)
|
||||
|
||||
val CustomizeHome =
|
||||
AppDestinationPreference<AppPreferences>(
|
||||
title = R.string.customize_home,
|
||||
destination = Destination.HomeSettings,
|
||||
summary = R.string.customize_home_summary,
|
||||
)
|
||||
|
||||
val SendCrashReports =
|
||||
AppSwitchPreference<AppPreferences>(
|
||||
title = R.string.send_crash_reports,
|
||||
|
|
@ -943,6 +950,14 @@ sealed interface AppPreference<Pref, T> {
|
|||
setter = { prefs, _ -> prefs },
|
||||
)
|
||||
|
||||
val QuickConnect =
|
||||
AppClickablePreference<AppPreferences>(
|
||||
title = R.string.quick_connect,
|
||||
summary = R.string.quick_connect_summary,
|
||||
getter = { },
|
||||
setter = { prefs, _ -> prefs },
|
||||
)
|
||||
|
||||
val SlideshowDuration =
|
||||
AppSliderPreference<AppPreferences>(
|
||||
title = R.string.slideshow_duration,
|
||||
|
|
@ -992,10 +1007,6 @@ val basicPreferences =
|
|||
preferences =
|
||||
listOf(
|
||||
AppPreference.SignInAuto,
|
||||
AppPreference.HomePageItems,
|
||||
AppPreference.CombineContinueNext,
|
||||
AppPreference.RewatchNextUp,
|
||||
AppPreference.MaxDaysNextUp,
|
||||
AppPreference.PlayThemeMusic,
|
||||
AppPreference.RememberSelectedTab,
|
||||
AppPreference.SubtitleStyle,
|
||||
|
|
@ -1026,6 +1037,7 @@ val basicPreferences =
|
|||
preferences =
|
||||
listOf(
|
||||
AppPreference.RequireProfilePin,
|
||||
AppPreference.CustomizeHome,
|
||||
AppPreference.UserPinnedNavDrawerItems,
|
||||
),
|
||||
),
|
||||
|
|
@ -1091,6 +1103,7 @@ val advancedPreferences =
|
|||
preferences =
|
||||
listOf(
|
||||
AppPreference.ShowClock,
|
||||
AppPreference.CombineContinueNext,
|
||||
// Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418
|
||||
// AppPreference.NavDrawerSwitchOnFocus,
|
||||
AppPreference.ControllerTimeout,
|
||||
|
|
@ -1168,6 +1181,7 @@ val advancedPreferences =
|
|||
title = R.string.more,
|
||||
preferences =
|
||||
listOf(
|
||||
AppPreference.QuickConnect,
|
||||
AppPreference.SendAppLogs,
|
||||
AppPreference.SendCrashReports,
|
||||
AppPreference.DebugLogging,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
|
@ -47,7 +48,12 @@ class BackdropService
|
|||
|
||||
suspend fun submit(item: BaseItem) =
|
||||
withContext(Dispatchers.IO) {
|
||||
val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)!!
|
||||
val imageUrl =
|
||||
if (item.type == BaseItemKind.GENRE) {
|
||||
item.imageUrlOverride
|
||||
} else {
|
||||
imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)!!
|
||||
}
|
||||
submit(item.id.toString(), imageUrl)
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -30,6 +30,9 @@ class ImageUrlService
|
|||
useSeriesForPrimary: Boolean,
|
||||
imageTags: Map<ImageType, String?>,
|
||||
imageType: ImageType,
|
||||
parentThumbId: UUID? = null,
|
||||
parentBackdropId: UUID? = null,
|
||||
backdropTags: List<String> = emptyList(),
|
||||
fillWidth: Int? = null,
|
||||
fillHeight: Int? = null,
|
||||
): 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.THUMB,
|
||||
ImageType.BANNER,
|
||||
-> {
|
||||
if (useSeriesForPrimary && seriesId != null &&
|
||||
|
|
@ -99,15 +159,19 @@ class ImageUrlService
|
|||
imageType: ImageType,
|
||||
fillWidth: Int? = null,
|
||||
fillHeight: Int? = null,
|
||||
useSeriesForPrimary: Boolean? = null,
|
||||
): String? =
|
||||
if (item != null) {
|
||||
getItemImageUrl(
|
||||
itemId = item.id,
|
||||
itemType = item.type,
|
||||
seriesId = item.data.seriesId,
|
||||
useSeriesForPrimary = item.useSeriesForPrimary,
|
||||
useSeriesForPrimary = useSeriesForPrimary ?: item.useSeriesForPrimary,
|
||||
imageTags = item.data.imageTags.orEmpty(),
|
||||
imageType = imageType,
|
||||
parentThumbId = item.data.parentThumbItemId,
|
||||
parentBackdropId = item.data.parentBackdropItemId,
|
||||
backdropTags = item.data.backdropImageTags.orEmpty(),
|
||||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ import android.content.Context
|
|||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
import com.github.damontecres.wholphin.ui.main.LatestData
|
||||
import com.github.damontecres.wholphin.ui.main.supportedLatestCollectionTypes
|
||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||
import com.github.damontecres.wholphin.util.supportItemKinds
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
|
|
@ -21,6 +19,8 @@ import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
|||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.UserDto
|
||||
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
||||
|
|
@ -44,6 +44,7 @@ class LatestNextUpService
|
|||
userId: UUID,
|
||||
limit: Int,
|
||||
includeEpisodes: Boolean,
|
||||
useSeriesForPrimary: Boolean = true,
|
||||
): List<BaseItem> {
|
||||
val request =
|
||||
GetResumeItemsRequest(
|
||||
|
|
@ -60,13 +61,19 @@ class LatestNextUpService
|
|||
remove(BaseItemKind.EPISODE)
|
||||
}
|
||||
},
|
||||
enableImageTypes =
|
||||
listOf(
|
||||
ImageType.PRIMARY,
|
||||
ImageType.THUMB,
|
||||
ImageType.BACKDROP,
|
||||
),
|
||||
)
|
||||
val items =
|
||||
api.itemsApi
|
||||
.getResumeItems(request)
|
||||
.content
|
||||
.items
|
||||
.map { BaseItem.from(it, api, true) }
|
||||
.map { BaseItem.from(it, api, useSeriesForPrimary) }
|
||||
return items
|
||||
}
|
||||
|
||||
|
|
@ -76,6 +83,7 @@ class LatestNextUpService
|
|||
enableRewatching: Boolean,
|
||||
enableResumable: Boolean,
|
||||
maxDays: Int,
|
||||
useSeriesForPrimary: Boolean = true,
|
||||
): List<BaseItem> {
|
||||
val nextUpDateCutoff =
|
||||
maxDays.takeIf { it > 0 }?.let { LocalDateTime.now().minusDays(it.toLong()) }
|
||||
|
|
@ -96,7 +104,7 @@ class LatestNextUpService
|
|||
.getNextUp(request)
|
||||
.content
|
||||
.items
|
||||
.map { BaseItem.from(it, api, true) }
|
||||
.map { BaseItem.from(it, api, useSeriesForPrimary) }
|
||||
return nextUp
|
||||
}
|
||||
|
||||
|
|
@ -192,3 +200,17 @@ class LatestNextUpService
|
|||
return@withContext result
|
||||
}
|
||||
}
|
||||
|
||||
val supportedLatestCollectionTypes =
|
||||
setOf(
|
||||
CollectionType.MOVIES,
|
||||
CollectionType.TVSHOWS,
|
||||
CollectionType.HOMEVIDEOS,
|
||||
// Exclude Live TV because a recording folder view will be used instead
|
||||
null, // Recordings & mixed collection types
|
||||
)
|
||||
|
||||
data class LatestData(
|
||||
val title: String,
|
||||
val request: GetLatestMediaRequest,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
@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
|
||||
*/
|
||||
|
|
@ -55,6 +40,18 @@ class RefreshRateService
|
|||
Timber.v("Not switching either refresh rate nor resolution")
|
||||
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
|
||||
require(stream.type == MediaStreamType.VIDEO) { "Stream is not video" }
|
||||
val width = stream.width
|
||||
|
|
|
|||
|
|
@ -44,18 +44,33 @@ class SeerrServerRepository
|
|||
private val serverRepository: ServerRepository,
|
||||
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
|
||||
) {
|
||||
private val _current = MutableStateFlow<CurrentSeerr?>(null)
|
||||
val current: StateFlow<CurrentSeerr?> = _current
|
||||
val currentServer: Flow<SeerrServer?> = current.map { it?.server }
|
||||
val currentUser: Flow<SeerrUser?> = current.map { it?.user }
|
||||
private val _connection =
|
||||
MutableStateFlow<SeerrConnectionStatus>(SeerrConnectionStatus.NotConfigured)
|
||||
val connection: StateFlow<SeerrConnectionStatus> = _connection
|
||||
|
||||
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
|
||||
*/
|
||||
val active: Flow<Boolean> = current.map { it != null && seerrApi.active }
|
||||
val active: Flow<Boolean> =
|
||||
connection.map { it is SeerrConnectionStatus.Success && seerrApi.active }
|
||||
|
||||
fun clear() {
|
||||
_current.update { null }
|
||||
_connection.update { SeerrConnectionStatus.NotConfigured }
|
||||
seerrApi.update("", null)
|
||||
}
|
||||
|
||||
fun error(
|
||||
serverUrl: String,
|
||||
exception: Exception,
|
||||
) {
|
||||
_connection.update { SeerrConnectionStatus.Error(serverUrl, exception) }
|
||||
seerrApi.update("", null)
|
||||
}
|
||||
|
||||
|
|
@ -65,8 +80,10 @@ class SeerrServerRepository
|
|||
userConfig: SeerrUserConfig,
|
||||
) {
|
||||
val publicSettings = seerrApi.api.settingsApi.settingsPublicGet()
|
||||
_current.update {
|
||||
CurrentSeerr(server, user, userConfig, publicSettings)
|
||||
_connection.update {
|
||||
SeerrConnectionStatus.Success(
|
||||
CurrentSeerr(server, user, userConfig, publicSettings),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -154,7 +171,7 @@ class SeerrServerRepository
|
|||
}
|
||||
|
||||
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)
|
||||
clear()
|
||||
}
|
||||
|
|
@ -165,6 +182,19 @@ class SeerrServerRepository
|
|||
*/
|
||||
typealias SeerrUserConfig = User
|
||||
|
||||
sealed interface SeerrConnectionStatus {
|
||||
data object NotConfigured : SeerrConnectionStatus
|
||||
|
||||
data class Error(
|
||||
val serverUrl: String,
|
||||
val ex: Exception,
|
||||
) : SeerrConnectionStatus
|
||||
|
||||
data class Success(
|
||||
val current: CurrentSeerr,
|
||||
) : SeerrConnectionStatus
|
||||
}
|
||||
|
||||
data class CurrentSeerr(
|
||||
val server: SeerrServer,
|
||||
val user: SeerrUser,
|
||||
|
|
@ -226,6 +256,7 @@ class UserSwitchListener
|
|||
private val seerrServerRepository: SeerrServerRepository,
|
||||
private val seerrServerDao: SeerrServerDao,
|
||||
private val seerrApi: SeerrApi,
|
||||
private val homeSettingsService: HomeSettingsService,
|
||||
) {
|
||||
init {
|
||||
context as AppCompatActivity
|
||||
|
|
@ -233,39 +264,45 @@ class UserSwitchListener
|
|||
serverRepository.currentUser.asFlow().collect { user ->
|
||||
Timber.d("New user")
|
||||
seerrServerRepository.clear()
|
||||
homeSettingsService.currentSettings.update { HomePageResolvedSettings.EMPTY }
|
||||
if (user != null) {
|
||||
// Check for home settings
|
||||
launchIO {
|
||||
homeSettingsService.loadCurrentSettings(user.id)
|
||||
}
|
||||
// Check for seerr server
|
||||
launchIO {
|
||||
seerrServerDao
|
||||
.getUsersByJellyfinUser(user.rowId)
|
||||
.firstOrNull()
|
||||
.lastOrNull()
|
||||
?.let { seerrUser ->
|
||||
val server = seerrServerDao.getServer(seerrUser.serverId)?.server
|
||||
val server =
|
||||
seerrServerDao.getServer(seerrUser.serverId)?.server
|
||||
if (server != null) {
|
||||
Timber.i("Found a seerr user & server")
|
||||
try {
|
||||
seerrApi.update(server.url, seerrUser.credential)
|
||||
val userConfig =
|
||||
if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
|
||||
try {
|
||||
login(
|
||||
seerrApi.api,
|
||||
seerrUser.authMethod,
|
||||
seerrUser.username,
|
||||
seerrUser.password,
|
||||
)
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(ex, "Error logging into %s", server.url)
|
||||
seerrServerRepository.clear()
|
||||
return@let
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
seerrApi.api.usersApi.authMeGet()
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(ex, "Error logging into %s", server.url)
|
||||
seerrServerRepository.clear()
|
||||
return@let
|
||||
}
|
||||
}
|
||||
seerrServerRepository.set(server, seerrUser, userConfig)
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(
|
||||
ex,
|
||||
"Error logging into %s",
|
||||
server.url,
|
||||
)
|
||||
seerrServerRepository.error(server.url, ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import androidx.work.WorkManager
|
|||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
|
|
@ -49,21 +50,8 @@ class SuggestionService
|
|||
.asFlow()
|
||||
.flatMapLatest { user ->
|
||||
val userId = user?.id ?: return@flatMapLatest flowOf(SuggestionsResource.Empty)
|
||||
val cachedIds = cache.get(userId, parentId, itemKind)?.ids.orEmpty()
|
||||
if (cachedIds.isNotEmpty()) {
|
||||
flow {
|
||||
try {
|
||||
emit(
|
||||
SuggestionsResource.Success(
|
||||
fetchItemsByIds(cachedIds, itemKind),
|
||||
),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to fetch items")
|
||||
emit(SuggestionsResource.Empty)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val cachedSuggestions = cache.get(userId, parentId, itemKind)
|
||||
if (cachedSuggestions == null) {
|
||||
workManager
|
||||
.getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME)
|
||||
.map { workInfos ->
|
||||
|
|
@ -73,6 +61,23 @@ class SuggestionService
|
|||
}
|
||||
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,11 +60,15 @@ class SuggestionsCache
|
|||
): CachedSuggestions? {
|
||||
val key = cacheKey(userId, libraryId, itemKind)
|
||||
return memoryCache.getOrPut(key) {
|
||||
try {
|
||||
mutex.withLock {
|
||||
File(cacheDir, "$key.json").inputStream().use {
|
||||
json.decodeFromStream<CachedSuggestions>(it)
|
||||
try {
|
||||
val cacheFile = File(cacheDir, "$key.json")
|
||||
if (!cacheFile.exists()) {
|
||||
return@withLock null
|
||||
}
|
||||
|
||||
cacheFile.inputStream().use {
|
||||
json.decodeFromStream<CachedSuggestions>(it)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Exception reading from disk cache")
|
||||
|
|
@ -72,6 +76,7 @@ class SuggestionsCache
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
suspend fun put(
|
||||
|
|
|
|||
|
|
@ -85,11 +85,8 @@ class SuggestionsWorker
|
|||
views
|
||||
.mapNotNull { view ->
|
||||
val itemKind =
|
||||
when (view.collectionType) {
|
||||
CollectionType.MOVIES -> BaseItemKind.MOVIE
|
||||
CollectionType.TVSHOWS -> BaseItemKind.SERIES
|
||||
else -> return@mapNotNull null
|
||||
}
|
||||
getTypeForCollection(view.collectionType)
|
||||
?: return@mapNotNull null
|
||||
async(Dispatchers.IO) {
|
||||
runCatching {
|
||||
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 PARAM_USER_ID = "userId"
|
||||
const val PARAM_SERVER_ID = "serverId"
|
||||
|
||||
fun getTypeForCollection(collectionType: CollectionType?): BaseItemKind? =
|
||||
when (collectionType) {
|
||||
CollectionType.MOVIES -> BaseItemKind.MOVIE
|
||||
CollectionType.TVSHOWS -> BaseItemKind.SERIES
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@ annotation class StandardOkHttpClient
|
|||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class IoCoroutineScope
|
||||
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class DefaultCoroutineScope
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AppModule {
|
||||
|
|
@ -177,6 +181,11 @@ object AppModule {
|
|||
@IoCoroutineScope
|
||||
fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@DefaultCoroutineScope
|
||||
fun defaultCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun workManager(
|
||||
|
|
|
|||
|
|
@ -80,7 +80,6 @@ class TvProviderWorker
|
|||
getPotentialItems(
|
||||
userId,
|
||||
prefs.homePagePreferences.enableRewatchingNextUp,
|
||||
prefs.homePagePreferences.combineContinueNext,
|
||||
prefs.homePagePreferences.maxDaysNextUp,
|
||||
)
|
||||
val potentialItemsToAddIds = potentialItemsToAdd.map { it.id.toString() }
|
||||
|
|
@ -145,7 +144,6 @@ class TvProviderWorker
|
|||
private suspend fun getPotentialItems(
|
||||
userId: UUID,
|
||||
enableRewatching: Boolean,
|
||||
combineContinueNext: Boolean,
|
||||
maxDaysNextUp: Int,
|
||||
): List<BaseItem> {
|
||||
val resumeItems = latestNextUpService.getResume(userId, 10, true)
|
||||
|
|
@ -154,11 +152,7 @@ class TvProviderWorker
|
|||
latestNextUpService
|
||||
.getNextUp(userId, 10, enableRewatching, false, maxDaysNextUp)
|
||||
.filter { it.data.seriesId != null && it.data.seriesId !in seriesIds }
|
||||
return if (combineContinueNext) {
|
||||
latestNextUpService.buildCombined(resumeItems, nextUpItems)
|
||||
} else {
|
||||
resumeItems + nextUpItems
|
||||
}
|
||||
return latestNextUpService.buildCombined(resumeItems, nextUpItems)
|
||||
}
|
||||
|
||||
private suspend fun getCurrentTvChannelNextUp(): List<WatchNextProgram> =
|
||||
|
|
|
|||
|
|
@ -391,6 +391,15 @@ fun CoroutineScope.launchIO(
|
|||
block: suspend CoroutineScope.() -> Unit,
|
||||
): Job = launch(context = Dispatchers.IO + context, start = start, block = block)
|
||||
|
||||
/**
|
||||
* Launches a coroutine with [Dispatchers.Default] plus the provided [CoroutineContext] defaulting to using [ExceptionHandler]
|
||||
*/
|
||||
fun CoroutineScope.launchDefault(
|
||||
context: CoroutineContext = ExceptionHandler(),
|
||||
start: CoroutineStart = CoroutineStart.DEFAULT,
|
||||
block: suspend CoroutineScope.() -> Unit,
|
||||
): Job = launch(context = Dispatchers.Default + context, start = start, block = block)
|
||||
|
||||
/**
|
||||
* Converts a UUID to the format used server-side (ie without hyphens).
|
||||
*
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import androidx.compose.foundation.text.appendInlineContent
|
|||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.WholphinApplication
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.MediaSegmentType
|
||||
import timber.log.Timber
|
||||
|
|
@ -76,7 +77,7 @@ val BaseItemDto.seriesProductionYears: String?
|
|||
append(productionYear.toString())
|
||||
if (status == "Continuing") {
|
||||
append(" - ")
|
||||
append("Present")
|
||||
append(WholphinApplication.instance.getString(R.string.series_continueing))
|
||||
} else if (status == "Ended") {
|
||||
endDate?.let {
|
||||
if (it.year != productionYear) {
|
||||
|
|
|
|||
|
|
@ -82,8 +82,10 @@ val PhotoItemFields =
|
|||
)
|
||||
|
||||
object Cards {
|
||||
val height2x3 = 172.dp
|
||||
val heightEpisode = height2x3 * .75f
|
||||
const val HEIGHT_2X3_DP = 172
|
||||
val height2x3 = HEIGHT_2X3_DP.dp
|
||||
const val HEIGHT_EPISODE = 128
|
||||
val heightEpisode = HEIGHT_EPISODE.dp
|
||||
val playedPercentHeight = 6.dp
|
||||
val serverUserCircle = height2x3 * .75f
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
package com.github.damontecres.wholphin.ui.cards
|
||||
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -24,6 +28,7 @@ import androidx.compose.ui.layout.ContentScale
|
|||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -41,6 +46,7 @@ import com.github.damontecres.wholphin.ui.AspectRatios
|
|||
import com.github.damontecres.wholphin.ui.Cards
|
||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||
import com.github.damontecres.wholphin.ui.enableMarquee
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
|
||||
/**
|
||||
|
|
@ -60,6 +66,9 @@ fun BannerCard(
|
|||
cardHeight: Dp = 120.dp,
|
||||
aspectRatio: Float = AspectRatios.WIDE,
|
||||
interactionSource: MutableInteractionSource? = null,
|
||||
imageType: ImageType = ImageType.PRIMARY,
|
||||
imageContentScale: ContentScale = ContentScale.FillBounds,
|
||||
useSeriesForPrimary: Boolean = true,
|
||||
) {
|
||||
val imageUrlService = LocalImageUrlService.current
|
||||
val density = LocalDensity.current
|
||||
|
|
@ -74,19 +83,21 @@ fun BannerCard(
|
|||
}
|
||||
}
|
||||
val imageUrl =
|
||||
remember(item, fillHeight) {
|
||||
remember(item, fillHeight, imageType, useSeriesForPrimary) {
|
||||
if (item != null) {
|
||||
imageUrlService.getItemImageUrl(
|
||||
item.imageUrlOverride
|
||||
?: imageUrlService.getItemImageUrl(
|
||||
item,
|
||||
ImageType.PRIMARY,
|
||||
imageType,
|
||||
fillWidth = null,
|
||||
fillHeight = fillHeight,
|
||||
useSeriesForPrimary = useSeriesForPrimary,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
var imageError by remember { mutableStateOf(false) }
|
||||
var imageError by remember(imageUrl) { mutableStateOf(false) }
|
||||
Card(
|
||||
modifier = modifier.size(cardHeight * aspectRatio, cardHeight),
|
||||
onClick = onClick,
|
||||
|
|
@ -107,7 +118,7 @@ fun BannerCard(
|
|||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.FillBounds,
|
||||
contentScale = imageContentScale,
|
||||
onError = { imageError = true },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
|
|
@ -115,7 +126,7 @@ fun BannerCard(
|
|||
Text(
|
||||
text = name ?: "",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier =
|
||||
Modifier
|
||||
|
|
@ -181,3 +192,84 @@ fun BannerCard(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BannerCardWithTitle(
|
||||
title: String?,
|
||||
subtitle: String?,
|
||||
item: BaseItem?,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
cornerText: String? = null,
|
||||
played: Boolean = false,
|
||||
favorite: Boolean = false,
|
||||
playPercent: Double = 0.0,
|
||||
cardHeight: Dp = 120.dp,
|
||||
aspectRatio: Float = AspectRatios.WIDE,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
imageType: ImageType = ImageType.PRIMARY,
|
||||
imageContentScale: ContentScale = ContentScale.FillBounds,
|
||||
useSeriesForPrimary: Boolean = item?.useSeriesForPrimary ?: true,
|
||||
) {
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
|
||||
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
|
||||
val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
|
||||
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
|
||||
val width = cardHeight * aspectRationToUse
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(spaceBetween),
|
||||
modifier = modifier.width(width),
|
||||
) {
|
||||
BannerCard(
|
||||
name = null,
|
||||
item = item,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = Modifier,
|
||||
cornerText = cornerText,
|
||||
played = played,
|
||||
favorite = favorite,
|
||||
playPercent = playPercent,
|
||||
cardHeight = cardHeight,
|
||||
aspectRatio = aspectRatio,
|
||||
interactionSource = interactionSource,
|
||||
imageType = imageType,
|
||||
imageContentScale = imageContentScale,
|
||||
useSeriesForPrimary = useSeriesForPrimary,
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(bottom = spaceBelow)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = title ?: "",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp)
|
||||
.enableMarquee(focusedAfterDelay),
|
||||
)
|
||||
Text(
|
||||
text = subtitle ?: "",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp)
|
||||
.enableMarquee(focusedAfterDelay),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import androidx.compose.ui.graphics.Color
|
|||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -183,6 +184,8 @@ fun DiscoverItemCard(
|
|||
text = item?.title ?: "",
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -193,6 +196,8 @@ fun DiscoverItemCard(
|
|||
text = item?.releaseDate?.year?.toString() ?: "",
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Normal,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
|
|||
|
|
@ -22,11 +22,13 @@ import androidx.compose.runtime.setValue
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.Card
|
||||
import androidx.tv.material3.CardDefaults
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.ui.AppColors
|
||||
|
|
@ -130,6 +132,8 @@ fun EpisodeCard(
|
|||
text = dto?.seriesName ?: "",
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -140,6 +144,8 @@ fun EpisodeCard(
|
|||
text = item?.name ?: "",
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Normal,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
|
|||
|
|
@ -42,11 +42,29 @@ fun GenreCard(
|
|||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) = GenreCard(
|
||||
genreId = genre?.id,
|
||||
name = genre?.name,
|
||||
imageUrl = genre?.imageUrl,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = modifier,
|
||||
interactionSource = interactionSource,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun GenreCard(
|
||||
genreId: UUID?,
|
||||
name: String?,
|
||||
imageUrl: String?,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
val background = rememberIdColor(genre?.id).copy(alpha = .6f)
|
||||
val background = rememberIdColor(genreId).copy(alpha = .6f)
|
||||
Card(
|
||||
modifier =
|
||||
modifier,
|
||||
modifier = modifier,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
interactionSource = interactionSource,
|
||||
|
|
@ -63,12 +81,12 @@ fun GenreCard(
|
|||
.fillMaxSize()
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
) {
|
||||
if (genre?.imageUrl.isNotNullOrBlank()) {
|
||||
if (imageUrl != null) {
|
||||
AsyncImage(
|
||||
model =
|
||||
ImageRequest
|
||||
.Builder(LocalContext.current)
|
||||
.data(genre.imageUrl)
|
||||
.data(imageUrl)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentScale = ContentScale.FillBounds,
|
||||
|
|
@ -88,7 +106,7 @@ fun GenreCard(
|
|||
.background(background),
|
||||
) {
|
||||
Text(
|
||||
text = genre?.name ?: "",
|
||||
text = name ?: "",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
|
|
@ -112,7 +130,6 @@ private fun GenreCardPreview() {
|
|||
UUID.randomUUID(),
|
||||
"Adventure",
|
||||
null,
|
||||
Color.Black,
|
||||
)
|
||||
GenreCard(
|
||||
genre = genre,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import androidx.compose.runtime.setValue
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -113,6 +114,8 @@ fun GridCard(
|
|||
text = item?.title ?: "",
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier =
|
||||
Modifier
|
||||
|
|
@ -124,6 +127,8 @@ fun GridCard(
|
|||
text = item?.subtitle ?: "",
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Normal,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Arrangement
|
|||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
|
|
@ -57,7 +58,7 @@ fun <T> ItemRow(
|
|||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
LazyRow(
|
||||
state = state,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import androidx.compose.ui.draw.clip
|
|||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
|
@ -174,6 +175,8 @@ fun PersonCard(
|
|||
text = name ?: "",
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -185,6 +188,8 @@ fun PersonCard(
|
|||
text = role,
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Normal,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
|
|||
|
|
@ -16,15 +16,16 @@ import androidx.compose.runtime.LaunchedEffect
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.Card
|
||||
import androidx.tv.material3.CardDefaults
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||
|
|
@ -126,21 +127,7 @@ fun SeasonCard(
|
|||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
|
||||
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
|
||||
var focusedAfterDelay by remember { mutableStateOf(false) }
|
||||
|
||||
val hideOverlayDelay = 500L
|
||||
if (focused) {
|
||||
LaunchedEffect(Unit) {
|
||||
delay(hideOverlayDelay)
|
||||
if (focused) {
|
||||
focusedAfterDelay = true
|
||||
} else {
|
||||
focusedAfterDelay = false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
focusedAfterDelay = false
|
||||
}
|
||||
val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
|
||||
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
|
||||
val width = imageHeight * aspectRationToUse
|
||||
val height = imageWidth * (1f / aspectRationToUse)
|
||||
|
|
@ -193,6 +180,8 @@ fun SeasonCard(
|
|||
text = title ?: "",
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -203,6 +192,8 @@ fun SeasonCard(
|
|||
text = subtitle ?: "",
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Normal,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -212,3 +203,22 @@ fun SeasonCard(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a [androidx.compose.runtime.State] which represents if the item has been focused for a while
|
||||
*/
|
||||
@Composable
|
||||
fun rememberFocusedAfterDelay(interactionSource: MutableInteractionSource): androidx.compose.runtime.State<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.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -33,6 +35,7 @@ import androidx.compose.ui.focus.FocusRequester
|
|||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
|
|
@ -41,6 +44,7 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
|
|
@ -60,6 +64,8 @@ import com.github.damontecres.wholphin.services.BackdropService
|
|||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.MediaReportService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
|
||||
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.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.ui.util.FilterUtils
|
||||
import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
|
||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
|
|
@ -120,7 +127,9 @@ class CollectionFolderViewModel
|
|||
private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
|
||||
private val favoriteWatchManager: FavoriteWatchManager,
|
||||
private val backdropService: BackdropService,
|
||||
val navigationManager: NavigationManager,
|
||||
private val navigationManager: NavigationManager,
|
||||
private val themeSongPlayer: ThemeSongPlayer,
|
||||
private val userPreferencesService: UserPreferencesService,
|
||||
val mediaReportService: MediaReportService,
|
||||
@Assisted itemId: String,
|
||||
@Assisted initialSortAndDirection: SortAndDirection?,
|
||||
|
|
@ -157,6 +166,7 @@ class CollectionFolderViewModel
|
|||
viewModelScope.launchIO {
|
||||
super.itemId = itemId
|
||||
try {
|
||||
val item =
|
||||
itemId.toUUIDOrNull()?.let {
|
||||
fetchItem(it)
|
||||
}
|
||||
|
|
@ -184,6 +194,8 @@ class CollectionFolderViewModel
|
|||
}
|
||||
|
||||
loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary)
|
||||
.join()
|
||||
// onResumePage()
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error during init")
|
||||
loading.setValueOnMain(DataLoadingState.Error(ex))
|
||||
|
|
@ -254,8 +266,7 @@ class CollectionFolderViewModel
|
|||
recursive: Boolean,
|
||||
filter: GetItemsFilter,
|
||||
useSeriesForPrimary: Boolean,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
) = viewModelScope.launch(Dispatchers.IO) {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (resetState) {
|
||||
loading.value = DataLoadingState.Loading
|
||||
|
|
@ -284,7 +295,6 @@ class CollectionFolderViewModel
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPager(
|
||||
sortAndDirection: SortAndDirection,
|
||||
|
|
@ -346,7 +356,12 @@ class CollectionFolderViewModel
|
|||
filter.applyTo(
|
||||
GetItemsRequest(
|
||||
parentId = item?.id,
|
||||
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
|
||||
enableImageTypes =
|
||||
listOf(
|
||||
ImageType.PRIMARY,
|
||||
ImageType.THUMB,
|
||||
ImageType.BACKDROP,
|
||||
),
|
||||
includeItemTypes = includeItemTypes,
|
||||
recursive = recursive,
|
||||
excludeItemIds = item?.let { listOf(item.id) },
|
||||
|
|
@ -438,6 +453,30 @@ class CollectionFolderViewModel
|
|||
backdropService.submit(item)
|
||||
}
|
||||
}
|
||||
|
||||
fun navigateTo(destination: Destination) {
|
||||
release()
|
||||
navigationManager.navigateTo(destination)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
themeSongPlayer.stop()
|
||||
}
|
||||
|
||||
fun onResumePage() {
|
||||
viewModelScope.launchIO {
|
||||
item.value?.let {
|
||||
Timber.v("onResumePage: %s", loading.value!!::class)
|
||||
if (it.type == BaseItemKind.BOX_SET && loading.value !is DataLoadingState.Error) {
|
||||
val volume =
|
||||
userPreferencesService
|
||||
.getCurrent()
|
||||
.appPreferences.interfacePreferences.playThemeSongs
|
||||
themeSongPlayer.playThemeFor(it.id, volume)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -531,7 +570,7 @@ fun CollectionFolderGrid(
|
|||
DataLoadingState.Loading,
|
||||
DataLoadingState.Pending,
|
||||
-> {
|
||||
LoadingPage()
|
||||
LoadingPage(modifier)
|
||||
}
|
||||
|
||||
is DataLoadingState.Error,
|
||||
|
|
@ -543,6 +582,13 @@ fun CollectionFolderGrid(
|
|||
?: item?.data?.collectionType?.name
|
||||
?: stringResource(R.string.collection)
|
||||
Box(modifier = modifier) {
|
||||
LifecycleResumeEffect(itemId) {
|
||||
viewModel.onResumePage()
|
||||
|
||||
onPauseOrDispose {
|
||||
viewModel.release()
|
||||
}
|
||||
}
|
||||
CollectionFolderGridContent(
|
||||
preferences = preferences,
|
||||
initialPosition = viewModel.position,
|
||||
|
|
@ -593,7 +639,7 @@ fun CollectionFolderGrid(
|
|||
} else {
|
||||
Destination.Playback(item)
|
||||
}
|
||||
viewModel.navigationManager.navigateTo(destination)
|
||||
viewModel.navigateTo(destination)
|
||||
},
|
||||
onClickPlayAll = { shuffle ->
|
||||
itemId.toUUIDOrNull()?.let {
|
||||
|
|
@ -617,7 +663,7 @@ fun CollectionFolderGrid(
|
|||
filter = filter,
|
||||
)
|
||||
}
|
||||
viewModel.navigationManager.navigateTo(destination)
|
||||
viewModel.navigateTo(destination)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
@ -655,7 +701,7 @@ fun CollectionFolderGrid(
|
|||
favorite = item.favorite,
|
||||
actions =
|
||||
MoreDialogActions(
|
||||
navigateTo = { viewModel.navigationManager.navigateTo(it) },
|
||||
navigateTo = { viewModel.navigateTo(it) },
|
||||
onClickWatch = { itemId, watched ->
|
||||
viewModel.setWatched(position, itemId, watched)
|
||||
},
|
||||
|
|
@ -693,6 +739,7 @@ fun CollectionFolderGrid(
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun CollectionFolderGridContent(
|
||||
preferences: UserPreferences,
|
||||
|
|
@ -837,14 +884,16 @@ fun CollectionFolderGridContent(
|
|||
}
|
||||
}
|
||||
}
|
||||
val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current
|
||||
val density = LocalDensity.current
|
||||
AnimatedVisibility(viewOptions.showDetails) {
|
||||
HomePageHeader(
|
||||
item = focusedItem,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(140.dp)
|
||||
.padding(16.dp),
|
||||
.height(200.dp)
|
||||
.padding(top = 48.dp, bottom = 32.dp, start = 8.dp),
|
||||
)
|
||||
}
|
||||
when (val state = loadingState) {
|
||||
|
|
@ -852,7 +901,7 @@ fun CollectionFolderGridContent(
|
|||
DataLoadingState.Loading,
|
||||
-> {
|
||||
// This shouldn't happen, so just show placeholder
|
||||
Text("Loading")
|
||||
Text(stringResource(R.string.loading))
|
||||
}
|
||||
|
||||
is DataLoadingState.Error -> {
|
||||
|
|
@ -890,6 +939,15 @@ fun CollectionFolderGridContent(
|
|||
},
|
||||
columns = viewOptions.columns,
|
||||
spacing = viewOptions.spacing.dp,
|
||||
bringIntoViewSpec =
|
||||
remember(viewOptions) {
|
||||
val spacingPx = with(density) { viewOptions.spacing.dp.toPx() }
|
||||
if (viewOptions.showDetails) {
|
||||
ScrollToTopBringIntoViewSpec(spacingPx)
|
||||
} else {
|
||||
defaultBringIntoViewSpec
|
||||
}
|
||||
},
|
||||
)
|
||||
AnimatedVisibility(showViewOptions) {
|
||||
ViewOptionsDialog(
|
||||
|
|
|
|||
|
|
@ -408,12 +408,13 @@ fun ConfirmDialog(
|
|||
onConfirm: () -> Unit,
|
||||
properties: DialogProperties = DialogProperties(),
|
||||
elevation: Dp = 8.dp,
|
||||
bodyColor: Color = MaterialTheme.colorScheme.onSurface,
|
||||
) = BasicDialog(
|
||||
onDismissRequest = onCancel,
|
||||
properties = properties,
|
||||
elevation = elevation,
|
||||
content = {
|
||||
ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier)
|
||||
ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier, bodyColor)
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -427,6 +428,7 @@ fun ConfirmDialogContent(
|
|||
onCancel: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
bodyColor: Color = MaterialTheme.colorScheme.onSurface,
|
||||
) {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
|
|
@ -446,7 +448,7 @@ fun ConfirmDialogContent(
|
|||
item {
|
||||
Text(
|
||||
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.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -21,8 +21,7 @@ import androidx.lifecycle.ViewModel
|
|||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
||||
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||
import com.github.damontecres.wholphin.data.model.createGenreDestination
|
||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
||||
|
|
@ -30,17 +29,18 @@ import com.github.damontecres.wholphin.ui.SlimItemFields
|
|||
import com.github.damontecres.wholphin.ui.cards.GenreCard
|
||||
import com.github.damontecres.wholphin.ui.detail.CardGrid
|
||||
import com.github.damontecres.wholphin.ui.detail.CardGridItem
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import com.mayakapps.kache.InMemoryKache
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
|
|
@ -56,8 +56,10 @@ import org.jellyfin.sdk.model.api.ItemFields
|
|||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.request.GetGenresRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
|
||||
@HiltViewModel(assistedFactory = GenreViewModel.Factory::class)
|
||||
class GenreViewModel
|
||||
|
|
@ -102,51 +104,23 @@ class GenreViewModel
|
|||
.execute(api, request)
|
||||
.content.items
|
||||
.map {
|
||||
Genre(it.id, it.name ?: "", null, Color.Black)
|
||||
Genre(it.id, it.name ?: "", null)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
this@GenreViewModel.genres.value = genres
|
||||
loading.value = LoadingState.Success
|
||||
}
|
||||
val genreToUrl = ConcurrentHashMap<UUID, String?>()
|
||||
val semaphore = Semaphore(4)
|
||||
genres
|
||||
.map { genre ->
|
||||
viewModelScope.async(Dispatchers.IO) {
|
||||
semaphore.withPermit {
|
||||
val item =
|
||||
GetItemsRequestHandler
|
||||
.execute(
|
||||
api,
|
||||
GetItemsRequest(
|
||||
val genreToUrl =
|
||||
getGenreImageMap(
|
||||
api = api,
|
||||
userId = serverRepository.currentUser.value?.id,
|
||||
scope = viewModelScope,
|
||||
imageUrlService = imageUrlService,
|
||||
genres = genres.map { it.id },
|
||||
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,
|
||||
cardWidthPx = cardWidthPx,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
val genresWithImages =
|
||||
genres.map {
|
||||
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(
|
||||
val id: UUID,
|
||||
val name: String,
|
||||
val imageUrl: String?,
|
||||
val color: Color,
|
||||
) : CardGridItem {
|
||||
override val gridId: String get() = id.toString()
|
||||
override val playable: Boolean = false
|
||||
|
|
@ -233,24 +280,13 @@ fun GenreCardGrid(
|
|||
pager = genres,
|
||||
onClickItem = { _, genre ->
|
||||
viewModel.navigationManager.navigateTo(
|
||||
Destination.FilteredCollection(
|
||||
itemId = itemId,
|
||||
filter =
|
||||
CollectionFolderFilter(
|
||||
nameOverride =
|
||||
listOfNotNull(
|
||||
genre.name,
|
||||
item?.title,
|
||||
).joinToString(" "),
|
||||
filter =
|
||||
GetItemsFilter(
|
||||
genres = listOf(genre.id),
|
||||
createGenreDestination(
|
||||
genreId = genre.id,
|
||||
genreName = genre.name,
|
||||
parentId = itemId,
|
||||
parentName = item?.title,
|
||||
includeItemTypes = includeItemTypes,
|
||||
),
|
||||
useSavedLibraryDisplayInfo = false,
|
||||
),
|
||||
recursive = true,
|
||||
),
|
||||
)
|
||||
},
|
||||
onLongClickItem = { _, _ -> },
|
||||
|
|
|
|||
|
|
@ -95,13 +95,13 @@ fun ItemGrid(
|
|||
val items by viewModel.items.observeAsState(listOf())
|
||||
when (val state = loading) {
|
||||
is LoadingState.Error -> {
|
||||
ErrorMessage(state)
|
||||
ErrorMessage(state, modifier)
|
||||
}
|
||||
|
||||
LoadingState.Loading,
|
||||
LoadingState.Pending,
|
||||
-> {
|
||||
LoadingPage()
|
||||
LoadingPage(modifier)
|
||||
}
|
||||
|
||||
LoadingState.Success -> {
|
||||
|
|
|
|||
|
|
@ -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.main.HomePageContent
|
||||
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.HomeRowLoadingState
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import java.util.UUID
|
||||
|
||||
|
|
@ -100,13 +101,13 @@ abstract class RecommendedViewModel(
|
|||
abstract fun update(
|
||||
@StringRes title: Int,
|
||||
row: HomeRowLoadingState,
|
||||
)
|
||||
): HomeRowLoadingState
|
||||
|
||||
fun update(
|
||||
@StringRes title: Int,
|
||||
block: suspend () -> List<BaseItem>,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
): Deferred<HomeRowLoadingState> =
|
||||
viewModelScope.async(Dispatchers.IO) {
|
||||
val titleStr = context.getString(title)
|
||||
val row =
|
||||
try {
|
||||
|
|
@ -115,9 +116,6 @@ abstract class RecommendedViewModel(
|
|||
HomeRowLoadingState.Error(titleStr, null, ex)
|
||||
}
|
||||
update(title, row)
|
||||
// TODO
|
||||
loading.setValueOnMain(LoadingState.Success)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -142,18 +140,20 @@ fun RecommendedContent(
|
|||
|
||||
when (val state = loading) {
|
||||
is LoadingState.Error -> {
|
||||
ErrorMessage(state)
|
||||
ErrorMessage(state, modifier)
|
||||
}
|
||||
|
||||
LoadingState.Loading,
|
||||
LoadingState.Pending,
|
||||
-> {
|
||||
LoadingPage()
|
||||
LoadingPage(modifier)
|
||||
}
|
||||
|
||||
LoadingState.Success -> {
|
||||
var position by rememberPosition()
|
||||
HomePageContent(
|
||||
homeRows = rows,
|
||||
position = position,
|
||||
onClickItem = { _, item ->
|
||||
viewModel.navigationManager.navigateTo(item.destination())
|
||||
},
|
||||
|
|
@ -163,7 +163,21 @@ fun RecommendedContent(
|
|||
onClickPlay = { _, item ->
|
||||
viewModel.navigationManager.navigateTo(Destination.Playback(item))
|
||||
},
|
||||
onFocusPosition = onFocusPosition,
|
||||
onFocusPosition = {
|
||||
position = it
|
||||
val nonEmptyRowBefore =
|
||||
rows
|
||||
.subList(0, it.row)
|
||||
.count {
|
||||
it is HomeRowLoadingState.Success && it.items.isEmpty()
|
||||
}
|
||||
onFocusPosition?.invoke(
|
||||
RowColumn(
|
||||
it.row - nonEmptyRowBefore,
|
||||
it.column,
|
||||
),
|
||||
)
|
||||
},
|
||||
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
||||
onUpdateBackdrop = viewModel::updateBackdrop,
|
||||
modifier = modifier,
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ import dagger.assisted.AssistedFactory
|
|||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
|
|
@ -123,6 +125,8 @@ class RecommendedMovieViewModel
|
|||
}
|
||||
}
|
||||
|
||||
val jobs = mutableListOf<Deferred<HomeRowLoadingState>>()
|
||||
|
||||
update(R.string.recently_released) {
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
|
|
@ -138,7 +142,7 @@ class RecommendedMovieViewModel
|
|||
enableTotalRecordCount = false,
|
||||
)
|
||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
|
||||
}
|
||||
}.also(jobs::add)
|
||||
|
||||
update(R.string.recently_added) {
|
||||
val request =
|
||||
|
|
@ -155,7 +159,7 @@ class RecommendedMovieViewModel
|
|||
enableTotalRecordCount = false,
|
||||
)
|
||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
|
||||
}
|
||||
}.also(jobs::add)
|
||||
|
||||
update(R.string.top_unwatched) {
|
||||
val request =
|
||||
|
|
@ -173,7 +177,7 @@ class RecommendedMovieViewModel
|
|||
enableTotalRecordCount = false,
|
||||
)
|
||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
|
||||
}
|
||||
}.also(jobs::add)
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
|
|
@ -204,6 +208,8 @@ class RecommendedMovieViewModel
|
|||
}
|
||||
update(R.string.suggestions, state)
|
||||
}
|
||||
} catch (ex: CancellationException) {
|
||||
throw ex
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Failed to fetch suggestions")
|
||||
update(
|
||||
|
|
@ -216,19 +222,29 @@ 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) {
|
||||
for (i in 0..<jobs.size) {
|
||||
val result = jobs[i].await()
|
||||
if (result.completed) {
|
||||
Timber.v("First success")
|
||||
loading.setValueOnMain(LoadingState.Success)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun update(
|
||||
@StringRes title: Int,
|
||||
row: HomeRowLoadingState,
|
||||
) {
|
||||
): HomeRowLoadingState {
|
||||
rows.update { current ->
|
||||
current.toMutableList().apply { set(rowTitles[title]!!, row) }
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ import dagger.assisted.AssistedFactory
|
|||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
|
@ -170,6 +172,8 @@ class RecommendedTvShowViewModel
|
|||
}
|
||||
}
|
||||
|
||||
val jobs = mutableListOf<Deferred<HomeRowLoadingState>>()
|
||||
|
||||
update(R.string.recently_released) {
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
|
|
@ -185,7 +189,7 @@ class RecommendedTvShowViewModel
|
|||
enableTotalRecordCount = false,
|
||||
)
|
||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, true)
|
||||
}
|
||||
}.also(jobs::add)
|
||||
|
||||
update(R.string.recently_added) {
|
||||
val request =
|
||||
|
|
@ -202,7 +206,7 @@ class RecommendedTvShowViewModel
|
|||
enableTotalRecordCount = false,
|
||||
)
|
||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, true)
|
||||
}
|
||||
}.also(jobs::add)
|
||||
|
||||
update(R.string.top_unwatched) {
|
||||
val request =
|
||||
|
|
@ -220,7 +224,7 @@ class RecommendedTvShowViewModel
|
|||
enableTotalRecordCount = false,
|
||||
)
|
||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, true)
|
||||
}
|
||||
}.also(jobs::add)
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
|
|
@ -251,6 +255,8 @@ class RecommendedTvShowViewModel
|
|||
}
|
||||
update(R.string.suggestions, state)
|
||||
}
|
||||
} catch (ex: CancellationException) {
|
||||
throw ex
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Failed to fetch suggestions")
|
||||
update(
|
||||
|
|
@ -264,18 +270,26 @@ class RecommendedTvShowViewModel
|
|||
}
|
||||
|
||||
if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun update(
|
||||
@StringRes title: Int,
|
||||
row: HomeRowLoadingState,
|
||||
) {
|
||||
): HomeRowLoadingState {
|
||||
rows.update { current ->
|
||||
current.toMutableList().apply { set(rowTitles[title]!!, row) }
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import androidx.annotation.StringRes
|
|||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.gestures.BringIntoViewSpec
|
||||
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
|
|
@ -24,6 +26,7 @@ import androidx.compose.foundation.lazy.layout.LazyLayoutCacheWindow
|
|||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
|
|
@ -112,6 +115,7 @@ fun <T : CardGridItem> CardGrid(
|
|||
},
|
||||
columns: Int = 6,
|
||||
spacing: Dp = 16.dp,
|
||||
bringIntoViewSpec: BringIntoViewSpec = LocalBringIntoViewSpec.current,
|
||||
) {
|
||||
val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0))
|
||||
|
||||
|
|
@ -269,6 +273,7 @@ fun <T : CardGridItem> CardGrid(
|
|||
Box(
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
CompositionLocalProvider(LocalBringIntoViewSpec provides bringIntoViewSpec) {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(columns),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing),
|
||||
|
|
@ -366,6 +371,7 @@ fun <T : CardGridItem> CardGrid(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val context = LocalContext.current
|
||||
val letters = context.getString(R.string.jump_letters)
|
||||
// Letters
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ fun CollectionFolderPhotoAlbum(
|
|||
} else {
|
||||
item.destination(index)
|
||||
}
|
||||
viewModel.navigationManager.navigateTo(destination)
|
||||
viewModel.navigateTo(destination)
|
||||
},
|
||||
itemId = itemId.toServerString(),
|
||||
initialFilter = filter,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
package com.github.damontecres.wholphin.ui.detail
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.view.Display
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
|
|
@ -32,11 +34,11 @@ import androidx.lifecycle.viewModelScope
|
|||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.BuildConfig
|
||||
import com.github.damontecres.wholphin.MainActivity
|
||||
import com.github.damontecres.wholphin.data.ItemPlaybackDao
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.RefreshRateService
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
|
|
@ -60,13 +62,19 @@ class DebugViewModel
|
|||
constructor(
|
||||
val serverRepository: ServerRepository,
|
||||
val itemPlaybackDao: ItemPlaybackDao,
|
||||
val refreshRateService: RefreshRateService,
|
||||
val clientInfo: ClientInfo,
|
||||
val deviceInfo: DeviceInfo,
|
||||
) : ViewModel() {
|
||||
val itemPlaybacks = MutableLiveData<List<ItemPlayback>>(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 {
|
||||
viewModelScope.launchIO {
|
||||
serverRepository.currentUser.value?.rowId?.let {
|
||||
|
|
@ -260,7 +268,7 @@ fun DebugPage(
|
|||
"Model: ${Build.MODEL}",
|
||||
"API Level: ${Build.VERSION.SDK_INT}",
|
||||
"Display Modes:",
|
||||
*viewModel.refreshRateService.supportedDisplayModes,
|
||||
*viewModel.supportedModes,
|
||||
).forEach {
|
||||
Text(
|
||||
text = it.toString(),
|
||||
|
|
|
|||
|
|
@ -113,13 +113,13 @@ fun DiscoverMovieDetails(
|
|||
|
||||
when (val state = loading) {
|
||||
is LoadingState.Error -> {
|
||||
ErrorMessage(state)
|
||||
ErrorMessage(state, modifier)
|
||||
}
|
||||
|
||||
LoadingState.Loading,
|
||||
LoadingState.Pending,
|
||||
-> {
|
||||
LoadingPage()
|
||||
LoadingPage(modifier)
|
||||
}
|
||||
|
||||
LoadingState.Success -> {
|
||||
|
|
|
|||
|
|
@ -73,7 +73,8 @@ class DiscoverMovieViewModel
|
|||
val canCancelRequest = MutableStateFlow(false)
|
||||
|
||||
val userConfig = seerrServerRepository.current.map { it?.config }
|
||||
val request4kEnabled = seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false }
|
||||
val request4kEnabled =
|
||||
seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false }
|
||||
|
||||
init {
|
||||
init()
|
||||
|
|
|
|||
|
|
@ -105,13 +105,13 @@ fun DiscoverSeriesDetails(
|
|||
|
||||
when (val state = loading) {
|
||||
is LoadingState.Error -> {
|
||||
ErrorMessage(state)
|
||||
ErrorMessage(state, modifier)
|
||||
}
|
||||
|
||||
LoadingState.Loading,
|
||||
LoadingState.Pending,
|
||||
-> {
|
||||
LoadingPage()
|
||||
LoadingPage(modifier)
|
||||
}
|
||||
|
||||
LoadingState.Success -> {
|
||||
|
|
|
|||
|
|
@ -102,13 +102,13 @@ fun EpisodeDetails(
|
|||
|
||||
when (val state = loading) {
|
||||
is LoadingState.Error -> {
|
||||
ErrorMessage(state)
|
||||
ErrorMessage(state, modifier)
|
||||
}
|
||||
|
||||
LoadingState.Loading,
|
||||
LoadingState.Pending,
|
||||
-> {
|
||||
LoadingPage()
|
||||
LoadingPage(modifier)
|
||||
}
|
||||
|
||||
LoadingState.Success -> {
|
||||
|
|
|
|||
|
|
@ -130,13 +130,13 @@ fun MovieDetails(
|
|||
|
||||
when (val state = loading) {
|
||||
is LoadingState.Error -> {
|
||||
ErrorMessage(state)
|
||||
ErrorMessage(state, modifier)
|
||||
}
|
||||
|
||||
LoadingState.Loading,
|
||||
LoadingState.Pending,
|
||||
-> {
|
||||
LoadingPage()
|
||||
LoadingPage(modifier)
|
||||
}
|
||||
|
||||
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.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusState
|
||||
|
|
@ -31,17 +32,17 @@ fun FocusedEpisodeHeader(
|
|||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
EpisodeName(dto, modifier = Modifier)
|
||||
EpisodeName(dto, modifier = Modifier.padding(start = 8.dp))
|
||||
|
||||
ep?.ui?.quickDetails?.let {
|
||||
QuickDetails(it, ep.timeRemainingOrRuntime)
|
||||
QuickDetails(it, ep.timeRemainingOrRuntime, Modifier.padding(start = 8.dp))
|
||||
}
|
||||
|
||||
if (dto != null) {
|
||||
VideoStreamDetails(
|
||||
chosenStreams = chosenStreams,
|
||||
numberOfVersions = dto.mediaSourceCount ?: 0,
|
||||
modifier = Modifier,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
OverviewText(
|
||||
|
|
|
|||
|
|
@ -120,13 +120,13 @@ fun SeriesDetails(
|
|||
|
||||
when (val state = loading) {
|
||||
is LoadingState.Error -> {
|
||||
ErrorMessage(state)
|
||||
ErrorMessage(state, modifier)
|
||||
}
|
||||
|
||||
LoadingState.Loading,
|
||||
LoadingState.Pending,
|
||||
-> {
|
||||
LoadingPage()
|
||||
LoadingPage(modifier)
|
||||
}
|
||||
|
||||
LoadingState.Success -> {
|
||||
|
|
@ -621,7 +621,7 @@ fun SeriesDetailsHeader(
|
|||
) {
|
||||
QuickDetails(series.ui.quickDetails, null, Modifier.padding(start = 8.dp))
|
||||
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 ->
|
||||
OverviewText(
|
||||
|
|
|
|||
|
|
@ -148,13 +148,13 @@ fun SeriesOverview(
|
|||
|
||||
when (val state = loading) {
|
||||
is LoadingState.Error -> {
|
||||
ErrorMessage(state)
|
||||
ErrorMessage(state, modifier)
|
||||
}
|
||||
|
||||
LoadingState.Loading,
|
||||
LoadingState.Pending,
|
||||
-> {
|
||||
LoadingPage()
|
||||
LoadingPage(modifier)
|
||||
}
|
||||
|
||||
LoadingState.Success -> {
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ fun SeriesOverviewContent(
|
|||
.onFocusChanged { pageHasFocus = it.hasFocus },
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.focusGroup()
|
||||
|
|
@ -159,9 +159,10 @@ fun SeriesOverviewContent(
|
|||
Modifier
|
||||
.focusRequester(tabRowFocusRequester)
|
||||
.padding(paddingValues)
|
||||
.padding(bottom = 4.dp)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
SeriesName(series.name, Modifier)
|
||||
SeriesName(series.name, Modifier.padding(start = 8.dp))
|
||||
FocusedEpisodeHeader(
|
||||
preferences = preferences,
|
||||
ep = focusedEpisode,
|
||||
|
|
@ -266,6 +267,7 @@ fun SeriesOverviewContent(
|
|||
},
|
||||
interactionSource = interactionSource,
|
||||
cardHeight = 120.dp,
|
||||
useSeriesForPrimary = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -294,8 +296,8 @@ fun SeriesOverviewContent(
|
|||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp),
|
||||
.padding(top = 4.dp)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package com.github.damontecres.wholphin.ui.discover
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
|
|
@ -11,6 +13,7 @@ import androidx.compose.foundation.layout.padding
|
|||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -23,6 +26,7 @@ import androidx.compose.ui.focus.FocusRequester
|
|||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
|
|
@ -47,6 +51,7 @@ import com.github.damontecres.wholphin.ui.listToDotString
|
|||
import com.github.damontecres.wholphin.ui.main.HomePageHeader
|
||||
import com.github.damontecres.wholphin.ui.rememberPosition
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import com.google.common.cache.CacheBuilder
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
|
|
@ -186,6 +191,7 @@ data class DiscoverState(
|
|||
val upcomingTv: DiscoverRowData = DiscoverRowData.EMPTY,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun SeerrDiscoverPage(
|
||||
preferences: UserPreferences,
|
||||
|
|
@ -249,6 +255,16 @@ fun SeerrDiscoverPage(
|
|||
.padding(top = 24.dp, bottom = 16.dp, start = 32.dp)
|
||||
.fillMaxHeight(.25f),
|
||||
)
|
||||
val density = LocalDensity.current
|
||||
val spaceAbovePx =
|
||||
with(density) {
|
||||
// The size of the row titles & spacing
|
||||
50.dp.toPx()
|
||||
}
|
||||
val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current
|
||||
CompositionLocalProvider(
|
||||
LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx),
|
||||
) {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 40.dp),
|
||||
|
|
@ -258,6 +274,7 @@ fun SeerrDiscoverPage(
|
|||
.fillMaxSize(),
|
||||
) {
|
||||
itemsIndexed(rows) { rowIndex, row ->
|
||||
CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) {
|
||||
DiscoverRow(
|
||||
row = row,
|
||||
onClickItem = { index, item ->
|
||||
|
|
@ -274,6 +291,8 @@ fun SeerrDiscoverPage(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ class SeerrRequestsViewModel
|
|||
viewModelScope.launchIO {
|
||||
backdropService.clearBackdrop()
|
||||
}
|
||||
seerrServerRepository.current
|
||||
seerrServerRepository.connection
|
||||
.onEach { user ->
|
||||
state.update { it.copy(requests = DataLoadingState.Loading) }
|
||||
if (user != null) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
package com.github.damontecres.wholphin.ui.main
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -16,10 +16,13 @@ import androidx.compose.foundation.layout.padding
|
|||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
|
@ -31,9 +34,9 @@ import androidx.compose.ui.focus.FocusRequester
|
|||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
|
|
@ -44,16 +47,19 @@ import androidx.tv.material3.MaterialTheme
|
|||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||
import com.github.damontecres.wholphin.ui.Cards
|
||||
import com.github.damontecres.wholphin.ui.cards.BannerCard
|
||||
import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle
|
||||
import com.github.damontecres.wholphin.ui.cards.GenreCard
|
||||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||
import com.github.damontecres.wholphin.ui.components.CircularProgress
|
||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||
import com.github.damontecres.wholphin.ui.components.EpisodeName
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.FocusableItemRow
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
||||
import com.github.damontecres.wholphin.ui.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.playback.isPlayKeyUp
|
||||
import com.github.damontecres.wholphin.ui.playback.playable
|
||||
import com.github.damontecres.wholphin.ui.playback.scale
|
||||
import com.github.damontecres.wholphin.ui.rememberPosition
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
|
||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import kotlinx.coroutines.delay
|
||||
|
|
@ -89,34 +97,37 @@ fun HomePage(
|
|||
LaunchedEffect(Unit) {
|
||||
viewModel.init()
|
||||
}
|
||||
val loading by viewModel.loadingState.observeAsState(LoadingState.Loading)
|
||||
val refreshing by viewModel.refreshState.observeAsState(LoadingState.Loading)
|
||||
val watchingRows by viewModel.watchingRows.observeAsState(listOf())
|
||||
val latestRows by viewModel.latestRows.observeAsState(listOf())
|
||||
|
||||
val homeRows = remember(watchingRows, latestRows) { watchingRows + latestRows }
|
||||
val state by viewModel.state.collectAsState()
|
||||
val loading = state.loadingState
|
||||
val refreshing = state.refreshState
|
||||
val homeRows = state.homeRows
|
||||
|
||||
when (val state = loading) {
|
||||
is LoadingState.Error -> {
|
||||
ErrorMessage(state)
|
||||
ErrorMessage(state, modifier)
|
||||
}
|
||||
|
||||
LoadingState.Loading,
|
||||
LoadingState.Pending,
|
||||
-> {
|
||||
LoadingPage()
|
||||
LoadingPage(modifier)
|
||||
}
|
||||
|
||||
LoadingState.Success -> {
|
||||
var dialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||
var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) }
|
||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
var position by rememberPosition()
|
||||
HomePageContent(
|
||||
homeRows = homeRows,
|
||||
onClickItem = { position, item ->
|
||||
position = position,
|
||||
onFocusPosition = { position = it },
|
||||
onClickItem = { clickedPosition, item ->
|
||||
position = clickedPosition
|
||||
viewModel.navigationManager.navigateTo(item.destination())
|
||||
},
|
||||
onLongClickItem = { position, item ->
|
||||
onLongClickItem = { clickedPosition, item ->
|
||||
position = clickedPosition
|
||||
val dialogItems =
|
||||
buildMoreDialogItemsForHome(
|
||||
context = context,
|
||||
|
|
@ -183,27 +194,31 @@ fun HomePage(
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun HomePageContent(
|
||||
homeRows: List<HomeRowLoadingState>,
|
||||
position: RowColumn,
|
||||
onFocusPosition: (RowColumn) -> Unit,
|
||||
onClickItem: (RowColumn, BaseItem) -> Unit,
|
||||
onLongClickItem: (RowColumn, BaseItem) -> Unit,
|
||||
onClickPlay: (RowColumn, BaseItem) -> Unit,
|
||||
showClock: Boolean,
|
||||
onUpdateBackdrop: (BaseItem) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onFocusPosition: ((RowColumn) -> Unit)? = null,
|
||||
loadingState: LoadingState? = null,
|
||||
listState: LazyListState = rememberLazyListState(),
|
||||
takeFocus: Boolean = true,
|
||||
showEmptyRows: Boolean = false,
|
||||
) {
|
||||
var position by rememberPosition()
|
||||
val focusedItem =
|
||||
position.let {
|
||||
(homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column)
|
||||
}
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
val rowFocusRequesters = remember(homeRows) { List(homeRows.size) { FocusRequester() } }
|
||||
var firstFocused by remember { mutableStateOf(false) }
|
||||
if (takeFocus) {
|
||||
LaunchedEffect(homeRows) {
|
||||
if (!firstFocused && homeRows.isNotEmpty()) {
|
||||
if (position.row >= 0) {
|
||||
|
|
@ -223,6 +238,7 @@ fun HomePageContent(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(position) {
|
||||
if (position.row >= 0) {
|
||||
listState.animateScrollToItem(position.row)
|
||||
|
|
@ -240,6 +256,16 @@ fun HomePageContent(
|
|||
.padding(top = 48.dp, bottom = 32.dp, start = 8.dp)
|
||||
.fillMaxHeight(.33f),
|
||||
)
|
||||
val density = LocalDensity.current
|
||||
val spaceAbovePx =
|
||||
with(density) {
|
||||
// The size of the row titles & spacing
|
||||
50.dp.toPx()
|
||||
}
|
||||
val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current
|
||||
CompositionLocalProvider(
|
||||
LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx),
|
||||
) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
|
|
@ -252,60 +278,32 @@ fun HomePageContent(
|
|||
.focusRestorer(),
|
||||
) {
|
||||
itemsIndexed(homeRows) { rowIndex, row ->
|
||||
CompositionLocalProvider(
|
||||
LocalBringIntoViewSpec provides defaultBringIntoViewSpec,
|
||||
) {
|
||||
when (val r = row) {
|
||||
is HomeRowLoadingState.Loading,
|
||||
is HomeRowLoadingState.Pending,
|
||||
-> {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
FocusableItemRow(
|
||||
title = r.title,
|
||||
subtitle = stringResource(R.string.loading),
|
||||
modifier = Modifier.animateItem(),
|
||||
) {
|
||||
Text(
|
||||
text = r.title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.loading),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is HomeRowLoadingState.Error -> {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.onFocusChanged {
|
||||
focused = it.isFocused
|
||||
}.focusable()
|
||||
.background(
|
||||
if (focused) {
|
||||
// Just so the user can tell it has focus
|
||||
MaterialTheme.colorScheme.border.copy(alpha = .25f)
|
||||
} else {
|
||||
Color.Unspecified
|
||||
},
|
||||
).animateItem(),
|
||||
) {
|
||||
Text(
|
||||
text = r.title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
FocusableItemRow(
|
||||
title = r.title,
|
||||
subtitle = r.localizedMessage,
|
||||
isError = true,
|
||||
modifier = Modifier.animateItem(),
|
||||
)
|
||||
Text(
|
||||
text = r.localizedMessage,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is HomeRowLoadingState.Success -> {
|
||||
if (row.items.isNotEmpty()) {
|
||||
val viewOptions = row.viewOptions
|
||||
ItemRow(
|
||||
title = row.title,
|
||||
items = row.items,
|
||||
|
|
@ -313,7 +311,10 @@ fun HomePageContent(
|
|||
onClickItem.invoke(RowColumn(rowIndex, index), item)
|
||||
},
|
||||
onLongClickItem = { index, item ->
|
||||
onLongClickItem.invoke(RowColumn(rowIndex, index), item)
|
||||
onLongClickItem.invoke(
|
||||
RowColumn(rowIndex, index),
|
||||
item,
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
|
|
@ -321,53 +322,44 @@ fun HomePageContent(
|
|||
.focusGroup()
|
||||
.focusRequester(rowFocusRequesters[rowIndex])
|
||||
.animateItem(),
|
||||
horizontalPadding = viewOptions.spacing.dp,
|
||||
cardContent = { index, item, cardModifier, onClick, onLongClick ->
|
||||
BannerCard(
|
||||
name = item?.data?.seriesName ?: item?.name,
|
||||
HomePageCardContent(
|
||||
index = index,
|
||||
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,
|
||||
viewOptions = viewOptions,
|
||||
modifier =
|
||||
cardModifier
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
position = RowColumn(rowIndex, index)
|
||||
// item?.let(onUpdateBackdrop)
|
||||
}
|
||||
if (it.isFocused && onFocusPosition != null) {
|
||||
val nonEmptyRowBefore =
|
||||
homeRows
|
||||
.subList(0, rowIndex)
|
||||
.count {
|
||||
it is HomeRowLoadingState.Success && it.items.isEmpty()
|
||||
}
|
||||
onFocusPosition.invoke(
|
||||
RowColumn(
|
||||
rowIndex - nonEmptyRowBefore,
|
||||
index,
|
||||
),
|
||||
onFocusPosition?.invoke(
|
||||
RowColumn(rowIndex, index),
|
||||
)
|
||||
}
|
||||
}.onKeyEvent {
|
||||
if (isPlayKeyUp(it) && item?.type?.playable == true) {
|
||||
Timber.v("Clicked play on ${item.id}")
|
||||
onClickPlay.invoke(position, item)
|
||||
onClickPlay.invoke(
|
||||
position,
|
||||
item,
|
||||
)
|
||||
return@onKeyEvent true
|
||||
}
|
||||
return@onKeyEvent false
|
||||
},
|
||||
interactionSource = null,
|
||||
cardHeight = Cards.height2x3,
|
||||
)
|
||||
},
|
||||
)
|
||||
} 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,
|
||||
overview = dto?.overview,
|
||||
overviewTwoLines = isEpisode,
|
||||
quickDetails = item?.ui?.quickDetails,
|
||||
quickDetails = item?.ui?.quickDetails ?: AnnotatedString(""),
|
||||
timeRemaining = item?.timeRemainingOrRuntime,
|
||||
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
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.NavDrawerItemRepository
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowConfig
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.DatePlayedService
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.LatestNextUpService
|
||||
import com.github.damontecres.wholphin.services.HomePageResolvedSettings
|
||||
import com.github.damontecres.wholphin.services.HomeSettingsService
|
||||
import com.github.damontecres.wholphin.services.MediaReportService
|
||||
import com.github.damontecres.wholphin.services.NavDrawerService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.services.tvAccess
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
|
@ -41,115 +43,122 @@ class HomeViewModel
|
|||
@Inject
|
||||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
val api: ApiClient,
|
||||
val navigationManager: NavigationManager,
|
||||
val serverRepository: ServerRepository,
|
||||
val navDrawerItemRepository: NavDrawerItemRepository,
|
||||
val mediaReportService: MediaReportService,
|
||||
private val navDrawerService: NavDrawerService,
|
||||
private val homeSettingsService: HomeSettingsService,
|
||||
private val favoriteWatchManager: FavoriteWatchManager,
|
||||
private val datePlayedService: DatePlayedService,
|
||||
private val latestNextUpService: LatestNextUpService,
|
||||
private val backdropService: BackdropService,
|
||||
private val userPreferencesService: UserPreferencesService,
|
||||
) : ViewModel() {
|
||||
val loadingState = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||
val refreshState = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||
val watchingRows = MutableLiveData<List<HomeRowLoadingState>>(listOf())
|
||||
val latestRows = MutableLiveData<List<HomeRowLoadingState>>(listOf())
|
||||
|
||||
private lateinit var preferences: UserPreferences
|
||||
private val _state = MutableStateFlow(HomeState.EMPTY)
|
||||
val state: StateFlow<HomeState> = _state
|
||||
|
||||
init {
|
||||
datePlayedService.invalidateAll()
|
||||
init()
|
||||
// init()
|
||||
}
|
||||
|
||||
fun init() {
|
||||
viewModelScope.launch(
|
||||
Dispatchers.IO +
|
||||
LoadingExceptionHandler(
|
||||
loadingState,
|
||||
"Error loading home page",
|
||||
),
|
||||
) {
|
||||
viewModelScope.launchIO {
|
||||
Timber.d("init HomeViewModel")
|
||||
val reload = loadingState.value != LoadingState.Success
|
||||
if (reload) {
|
||||
loadingState.setValueOnMain(LoadingState.Loading)
|
||||
}
|
||||
refreshState.setValueOnMain(LoadingState.Loading)
|
||||
this@HomeViewModel.preferences = userPreferencesService.getCurrent()
|
||||
val prefs = preferences.appPreferences.homePagePreferences
|
||||
val limit = prefs.maxItemsPerRow
|
||||
if (reload) {
|
||||
backdropService.clearBackdrop()
|
||||
}
|
||||
try {
|
||||
val preferences = userPreferencesService.getCurrent()
|
||||
val prefs = preferences.appPreferences.homePagePreferences
|
||||
|
||||
serverRepository.currentUserDto.value?.let { userDto ->
|
||||
val includedIds =
|
||||
navDrawerItemRepository
|
||||
.getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems())
|
||||
.filter { it is ServerNavDrawerItem }
|
||||
.map { (it as ServerNavDrawerItem).itemId }
|
||||
val resume = latestNextUpService.getResume(userDto.id, limit, true)
|
||||
val nextUp =
|
||||
latestNextUpService.getNextUp(
|
||||
userDto.id,
|
||||
limit,
|
||||
prefs.enableRewatchingNextUp,
|
||||
false,
|
||||
prefs.maxDaysNextUp,
|
||||
)
|
||||
val watching =
|
||||
buildList {
|
||||
if (prefs.combineContinueNext) {
|
||||
val items = latestNextUpService.buildCombined(resume, nextUp)
|
||||
add(
|
||||
HomeRowLoadingState.Success(
|
||||
title = context.getString(R.string.continue_watching),
|
||||
items = items,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
if (resume.isNotEmpty()) {
|
||||
add(
|
||||
HomeRowLoadingState.Success(
|
||||
title = context.getString(R.string.continue_watching),
|
||||
items = resume,
|
||||
),
|
||||
)
|
||||
val libraries =
|
||||
navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess)
|
||||
val settings =
|
||||
homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY }
|
||||
val state = state.value
|
||||
|
||||
// Refreshing if a load has already occurred and the rows haven't significantly changed
|
||||
val refresh =
|
||||
state.loadingState == LoadingState.Success && state.settings == settings
|
||||
Timber.v("refresh=$refresh, state.loadingState=${state.loadingState}")
|
||||
_state.update { it.copy(settings = settings) }
|
||||
|
||||
val semaphore = Semaphore(4)
|
||||
|
||||
val watchingRowIndexes =
|
||||
settings.rows
|
||||
.mapIndexedNotNull { index, row ->
|
||||
if (isWatchingRow(row.config)) index else null
|
||||
}
|
||||
if (nextUp.isNotEmpty()) {
|
||||
add(
|
||||
HomeRowLoadingState.Success(
|
||||
title = context.getString(R.string.next_up),
|
||||
items = nextUp,
|
||||
),
|
||||
val deferred =
|
||||
settings.rows
|
||||
// Load the watching rows first
|
||||
.sortedByDescending { isWatchingRow(it.config) }
|
||||
.map { row ->
|
||||
viewModelScope.async(Dispatchers.IO) {
|
||||
semaphore.withPermit {
|
||||
Timber.v("Fetching row: %s", row)
|
||||
try {
|
||||
homeSettingsService.fetchDataForRow(
|
||||
row = row.config,
|
||||
scope = viewModelScope,
|
||||
prefs = prefs,
|
||||
userDto = userDto,
|
||||
libraries = libraries,
|
||||
limit = prefs.maxItemsPerRow,
|
||||
isRefresh = refresh,
|
||||
)
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error on row %s", row)
|
||||
HomeRowLoadingState.Error(row.title, exception = ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val latest = latestNextUpService.getLatest(userDto, limit, includedIds)
|
||||
val pendingLatest = latest.map { HomeRowLoadingState.Loading(it.title) }
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
this@HomeViewModel.watchingRows.value = watching
|
||||
if (reload) {
|
||||
this@HomeViewModel.latestRows.value = pendingLatest
|
||||
if (refresh && state.homeRows.isNotEmpty() && watchingRowIndexes.isNotEmpty()) {
|
||||
// Replace watching rows first
|
||||
Timber.v("Refreshing rows: %s", watchingRowIndexes)
|
||||
val rows =
|
||||
deferred
|
||||
.filterIndexed { index, _ -> index in watchingRowIndexes }
|
||||
.awaitAll()
|
||||
_state.update {
|
||||
val newRows =
|
||||
it.homeRows.toMutableList().apply {
|
||||
rows.forEachIndexed { index, row ->
|
||||
set(watchingRowIndexes[index], row)
|
||||
}
|
||||
loadingState.value = LoadingState.Success
|
||||
}
|
||||
refreshState.setValueOnMain(LoadingState.Success)
|
||||
val loadedLatest = latestNextUpService.loadLatest(latest)
|
||||
this@HomeViewModel.latestRows.setValueOnMain(loadedLatest)
|
||||
it.copy(
|
||||
loadingState = LoadingState.Success,
|
||||
homeRows = newRows,
|
||||
)
|
||||
}
|
||||
}
|
||||
val rows =
|
||||
deferred
|
||||
.awaitAll()
|
||||
.filter {
|
||||
// Include only errors & non-empty successes
|
||||
it is HomeRowLoadingState.Error ||
|
||||
(it is HomeRowLoadingState.Success && it.items.isNotEmpty())
|
||||
}
|
||||
Timber.v("Got all rows")
|
||||
_state.update {
|
||||
it.copy(
|
||||
loadingState = LoadingState.Success,
|
||||
refreshState = LoadingState.Success,
|
||||
homeRows = rows,
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex)
|
||||
if (!reload) {
|
||||
loadingState.setValueOnMain(LoadingState.Error(ex))
|
||||
} else {
|
||||
Timber.e(ex, "Exception during home page loading")
|
||||
if (state.value.loadingState == LoadingState.Success) {
|
||||
showToast(context, "Error refreshing home: ${ex.localizedMessage}")
|
||||
} else {
|
||||
_state.update {
|
||||
it.copy(loadingState = LoadingState.Error(ex))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -182,16 +191,27 @@ class HomeViewModel
|
|||
}
|
||||
}
|
||||
|
||||
val supportedLatestCollectionTypes =
|
||||
setOf(
|
||||
CollectionType.MOVIES,
|
||||
CollectionType.TVSHOWS,
|
||||
CollectionType.HOMEVIDEOS,
|
||||
// Exclude Live TV because a recording folder view will be used instead
|
||||
null, // Recordings & mixed collection types
|
||||
data class HomeState(
|
||||
val loadingState: LoadingState,
|
||||
val refreshState: LoadingState,
|
||||
val homeRows: List<HomeRowLoadingState>,
|
||||
val settings: HomePageResolvedSettings,
|
||||
) {
|
||||
companion object {
|
||||
val EMPTY =
|
||||
HomeState(
|
||||
LoadingState.Pending,
|
||||
LoadingState.Pending,
|
||||
listOf(),
|
||||
HomePageResolvedSettings.EMPTY,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class LatestData(
|
||||
val title: String,
|
||||
val request: GetLatestMediaRequest,
|
||||
)
|
||||
/**
|
||||
* Whether a row is a "is watching" type
|
||||
*/
|
||||
private fun isWatchingRow(row: HomeRowConfig) =
|
||||
row is HomeRowConfig.ContinueWatching ||
|
||||
row is HomeRowConfig.NextUp ||
|
||||
row is HomeRowConfig.ContinueWatchingCombined
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) : Destination()
|
||||
|
||||
@Serializable
|
||||
data object HomeSettings : Destination(true)
|
||||
|
||||
@Serializable
|
||||
data class Settings(
|
||||
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.main.HomePage
|
||||
import com.github.damontecres.wholphin.ui.main.SearchPage
|
||||
import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsPage
|
||||
import com.github.damontecres.wholphin.ui.playback.PlaybackPage
|
||||
import com.github.damontecres.wholphin.ui.preferences.PreferencesPage
|
||||
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage
|
||||
|
|
@ -66,6 +67,10 @@ fun DestinationContent(
|
|||
)
|
||||
}
|
||||
|
||||
is Destination.HomeSettings -> {
|
||||
HomeSettingsPage(modifier)
|
||||
}
|
||||
|
||||
is Destination.PlaybackList,
|
||||
is Destination.Playback,
|
||||
-> {
|
||||
|
|
@ -119,7 +124,9 @@ fun DestinationContent(
|
|||
)
|
||||
}
|
||||
|
||||
BaseItemKind.VIDEO -> {
|
||||
BaseItemKind.VIDEO,
|
||||
BaseItemKind.MUSIC_VIDEO,
|
||||
-> {
|
||||
// TODO Use VideoDetails
|
||||
MovieDetails(
|
||||
preferences,
|
||||
|
|
@ -220,7 +227,7 @@ fun DestinationContent(
|
|||
|
||||
else -> {
|
||||
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.LaunchedEffect
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -66,20 +67,19 @@ import androidx.tv.material3.ProvideTextStyle
|
|||
import androidx.tv.material3.Text
|
||||
import androidx.tv.material3.surfaceColorAtElevation
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.NavDrawerItemRepository
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.preferences.AppThemeColors
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.NavDrawerService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.SeerrServerRepository
|
||||
import com.github.damontecres.wholphin.services.SetupDestination
|
||||
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||
import com.github.damontecres.wholphin.ui.components.TimeDisplay
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.setup.UserIconCardImage
|
||||
|
|
@ -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.tryRequestFocus
|
||||
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.extensions.imageApi
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
|
|
@ -104,42 +100,66 @@ class NavDrawerViewModel
|
|||
@Inject
|
||||
constructor(
|
||||
private val api: ApiClient,
|
||||
private val navDrawerItemRepository: NavDrawerItemRepository,
|
||||
private val navDrawerService: NavDrawerService,
|
||||
val navigationManager: NavigationManager,
|
||||
val setupNavigationManager: SetupNavigationManager,
|
||||
val backdropService: BackdropService,
|
||||
private val seerrServerRepository: SeerrServerRepository,
|
||||
) : ViewModel() {
|
||||
val moreLibraries = MutableLiveData<List<NavDrawerItem>>(null)
|
||||
val libraries = MutableLiveData<List<NavDrawerItem>>(listOf())
|
||||
val state = navDrawerService.state
|
||||
|
||||
val selectedIndex = MutableLiveData(-1)
|
||||
val showMore = MutableLiveData(false)
|
||||
val moreExpanded = MutableLiveData(false)
|
||||
|
||||
init {
|
||||
seerrServerRepository.active
|
||||
.onEach {
|
||||
init()
|
||||
}.launchIn(viewModelScope)
|
||||
fun onClickDrawerItem(
|
||||
index: Int,
|
||||
item: NavDrawerItem,
|
||||
) {
|
||||
if (item !is NavDrawerItem.More) setShowMore(false)
|
||||
when (item) {
|
||||
NavDrawerItem.Favorites -> {
|
||||
setIndex(index)
|
||||
navigationManager.navigateToFromDrawer(
|
||||
Destination.Favorites,
|
||||
)
|
||||
}
|
||||
|
||||
fun init() {
|
||||
viewModelScope.launchIO {
|
||||
val all = navDrawerItemRepository.getNavDrawerItems()
|
||||
val libraries = navDrawerItemRepository.getFilteredNavDrawerItems(all)
|
||||
val moreLibraries = all.toMutableList().apply { removeAll(libraries) }
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
this@NavDrawerViewModel.moreLibraries.value = moreLibraries
|
||||
this@NavDrawerViewModel.libraries.value = libraries
|
||||
NavDrawerItem.More -> {
|
||||
setShowMore(!moreExpanded.value!!)
|
||||
}
|
||||
|
||||
NavDrawerItem.Discover -> {
|
||||
setIndex(index)
|
||||
navigationManager.navigateToFromDrawer(
|
||||
Destination.Discover,
|
||||
)
|
||||
}
|
||||
|
||||
is ServerNavDrawerItem -> {
|
||||
setIndex(index)
|
||||
navigationManager.navigateToFromDrawer(item.destination)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setIndex(index: Int) {
|
||||
selectedIndex.value = index
|
||||
}
|
||||
|
||||
fun setShowMore(value: Boolean) {
|
||||
moreExpanded.value = value
|
||||
}
|
||||
|
||||
fun getUserImage(user: JellyfinUser): String = api.imageApi.getUserImageUrl(user.id)
|
||||
|
||||
fun updateSelectedIndex() {
|
||||
viewModelScope.launchDefault {
|
||||
val asDestinations =
|
||||
(
|
||||
libraries +
|
||||
state.value.items +
|
||||
listOf(
|
||||
NavDrawerItem.More,
|
||||
NavDrawerItem.Discover,
|
||||
) + moreLibraries
|
||||
) + state.value.moreItems
|
||||
).map {
|
||||
if (it is ServerNavDrawerItem) {
|
||||
it.destination
|
||||
|
|
@ -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 {
|
||||
|
|
@ -251,13 +225,6 @@ sealed interface NavDrawerItem {
|
|||
|
||||
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(
|
||||
|
|
@ -266,9 +233,13 @@ data class ServerNavDrawerItem(
|
|||
val destination: Destination,
|
||||
val type: CollectionType,
|
||||
) : NavDrawerItem {
|
||||
override val id: String = "s_" + itemId.toServerString()
|
||||
override val id: String = getId(itemId)
|
||||
|
||||
override fun name(context: Context): String = name
|
||||
|
||||
companion object {
|
||||
fun getId(itemId: UUID) = "s_" + itemId.toServerString()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -290,6 +261,7 @@ fun NavDrawer(
|
|||
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 context = LocalContext.current
|
||||
val density = LocalDensity.current
|
||||
|
|
@ -301,15 +273,12 @@ fun NavDrawer(
|
|||
drawerState.setValue(DrawerValue.Open)
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
val moreLibraries by viewModel.moreLibraries.observeAsState(listOf())
|
||||
val libraries by viewModel.libraries.observeAsState(listOf())
|
||||
LaunchedEffect(Unit) { viewModel.init() }
|
||||
|
||||
val showMore by viewModel.showMore.observeAsState(false)
|
||||
// A negative index is a built in page, >=0 is a library
|
||||
val state by viewModel.state.collectAsState()
|
||||
val moreExpanded by viewModel.moreExpanded.observeAsState(false)
|
||||
// A negative index is a built-in page, >=0 is a library
|
||||
val selectedIndex by viewModel.selectedIndex.observeAsState(-1)
|
||||
|
||||
BackHandler(enabled = showMore && drawerState.currentValue == DrawerValue.Open) {
|
||||
BackHandler(enabled = moreExpanded && drawerState.currentValue == DrawerValue.Open) {
|
||||
viewModel.setShowMore(false)
|
||||
}
|
||||
|
||||
|
|
@ -424,12 +393,13 @@ fun NavDrawer(
|
|||
),
|
||||
)
|
||||
}
|
||||
itemsIndexed(libraries) { index, it ->
|
||||
itemsIndexed(state.items) { index, it ->
|
||||
if (it !is NavDrawerItem.Discover || state.discoverEnabled) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
NavItem(
|
||||
library = it,
|
||||
selected = selectedIndex == index,
|
||||
moreExpanded = showMore,
|
||||
moreExpanded = moreExpanded,
|
||||
drawerOpen = isOpen,
|
||||
interactionSource = interactionSource,
|
||||
onClick = {
|
||||
|
|
@ -443,16 +413,46 @@ fun NavDrawer(
|
|||
),
|
||||
)
|
||||
}
|
||||
if (showMore) {
|
||||
itemsIndexed(moreLibraries) { index, it ->
|
||||
val adjustedIndex = (index + libraries.size + 1)
|
||||
}
|
||||
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 = showMore,
|
||||
moreExpanded = moreExpanded,
|
||||
drawerOpen = isOpen,
|
||||
onClick = { viewModel.onClickDrawerItem(adjustedIndex, it) },
|
||||
onClick = {
|
||||
viewModel.onClickDrawerItem(
|
||||
adjustedIndex,
|
||||
it,
|
||||
)
|
||||
},
|
||||
containerColor =
|
||||
if (isOpen) {
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)
|
||||
|
|
@ -469,6 +469,7 @@ fun NavDrawer(
|
|||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
IconNavItem(
|
||||
|
|
@ -637,10 +638,6 @@ fun NavigationDrawerScope.NavItem(
|
|||
else -> R.string.fa_film
|
||||
}
|
||||
}
|
||||
|
||||
NavDrawerItem.NowPlaying -> {
|
||||
R.string.fa_circle_play
|
||||
}
|
||||
}
|
||||
}
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
package com.github.damontecres.wholphin.ui.playback
|
||||
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.Chapter
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.TrickplayInfo
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
|
||||
data class CurrentMediaInfo(
|
||||
val sourceId: String?,
|
||||
|
|
@ -15,3 +20,22 @@ data class CurrentMediaInfo(
|
|||
val EMPTY = CurrentMediaInfo(null, null, listOf(), listOf(), listOf(), null)
|
||||
}
|
||||
}
|
||||
|
||||
fun BaseItem.toMediaMetadata(imageUrl: String?): MediaMetadata =
|
||||
MediaMetadata
|
||||
.Builder()
|
||||
.setTitle(title)
|
||||
.setSubtitle(subtitle)
|
||||
.setReleaseYear(data.productionYear)
|
||||
.setDescription(data.overview)
|
||||
.setArtworkUri(imageUrl?.toUri())
|
||||
.setDurationMs(data.runTimeTicks?.ticks?.inWholeMilliseconds)
|
||||
.setMediaType(
|
||||
when (type) {
|
||||
BaseItemKind.MOVIE -> MediaMetadata.MEDIA_TYPE_MOVIE
|
||||
BaseItemKind.EPISODE -> MediaMetadata.MEDIA_TYPE_TV_SHOW
|
||||
BaseItemKind.VIDEO -> MediaMetadata.MEDIA_TYPE_VIDEO
|
||||
BaseItemKind.TV_CHANNEL, BaseItemKind.CHANNEL, BaseItemKind.LIVE_TV_CHANNEL -> MediaMetadata.MEDIA_TYPE_TV_CHANNEL
|
||||
else -> MediaMetadata.MEDIA_TYPE_VIDEO
|
||||
},
|
||||
).build()
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ fun DownloadSubtitlesContent(
|
|||
.padding(PaddingValues(24.dp)),
|
||||
) {
|
||||
Text(
|
||||
text = "Search & download subtitles",
|
||||
text = stringResource(R.string.search_and_download_subtitles),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
|
@ -113,7 +113,7 @@ fun DownloadSubtitlesContent(
|
|||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Language",
|
||||
text = stringResource(R.string.language),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
|
@ -137,7 +137,7 @@ fun DownloadSubtitlesContent(
|
|||
}
|
||||
if (dialogItems.isEmpty()) {
|
||||
Text(
|
||||
text = "No remote subtitles were found",
|
||||
text = stringResource(R.string.no_subtitles_found),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,20 +1,22 @@
|
|||
package com.github.damontecres.wholphin.ui.playback
|
||||
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.preferences.PrefContentScale
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
|
||||
val playbackSpeedOptions = listOf(".25", ".5", ".75", "1.0", "1.25", "1.5", "1.75", "2.0")
|
||||
|
||||
val playbackScaleOptions =
|
||||
mapOf(
|
||||
ContentScale.Fit to "Fit",
|
||||
ContentScale.None to "None",
|
||||
ContentScale.Crop to "Crop",
|
||||
ContentScale.Fit to R.string.content_scale_fit,
|
||||
ContentScale.None to R.string.none,
|
||||
ContentScale.Crop to R.string.content_scale_crop,
|
||||
// ContentScale.Inside to "Inside",
|
||||
ContentScale.FillBounds to "Fill",
|
||||
ContentScale.FillWidth to "Fill Width",
|
||||
ContentScale.FillHeight to "Fill Height",
|
||||
ContentScale.FillBounds to R.string.content_scale_fill,
|
||||
ContentScale.FillWidth to R.string.content_scale_fill_width,
|
||||
ContentScale.FillHeight to R.string.content_scale_fill_height,
|
||||
)
|
||||
|
||||
val PrefContentScale.scale: ContentScale
|
||||
|
|
@ -81,3 +83,20 @@ val BaseItemKind.playable: Boolean
|
|||
BaseItemKind.YEAR,
|
||||
-> false
|
||||
}
|
||||
|
||||
fun getTypeFor(collectionType: CollectionType): BaseItemKind? =
|
||||
when (collectionType) {
|
||||
CollectionType.UNKNOWN -> null
|
||||
CollectionType.MOVIES -> BaseItemKind.MOVIE
|
||||
CollectionType.TVSHOWS -> BaseItemKind.SERIES
|
||||
CollectionType.MUSIC -> BaseItemKind.AUDIO
|
||||
CollectionType.MUSICVIDEOS -> BaseItemKind.MUSIC_VIDEO
|
||||
CollectionType.TRAILERS -> BaseItemKind.TRAILER
|
||||
CollectionType.HOMEVIDEOS -> BaseItemKind.VIDEO
|
||||
CollectionType.BOXSETS -> BaseItemKind.BOX_SET
|
||||
CollectionType.BOOKS -> BaseItemKind.BOOK
|
||||
CollectionType.PHOTOS -> BaseItemKind.PHOTO_ALBUM
|
||||
CollectionType.LIVETV -> BaseItemKind.LIVE_TV_CHANNEL
|
||||
CollectionType.PLAYLISTS -> BaseItemKind.PLAYLIST
|
||||
CollectionType.FOLDERS -> BaseItemKind.FOLDER
|
||||
}
|
||||
|
|
|
|||
|
|
@ -144,7 +144,9 @@ fun PlaybackDialog(
|
|||
BottomDialogItem(
|
||||
data = PlaybackDialogType.VIDEO_SCALE,
|
||||
headline = stringResource(R.string.video_scale),
|
||||
supporting = playbackScaleOptions[settings.contentScale],
|
||||
supporting =
|
||||
playbackScaleOptions[settings.contentScale]
|
||||
?.let { stringResource(it) },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -220,7 +222,7 @@ fun PlaybackDialog(
|
|||
playbackScaleOptions.map { (scale, name) ->
|
||||
BottomDialogItem(
|
||||
data = scale,
|
||||
headline = name,
|
||||
headline = stringResource(name),
|
||||
supporting = null,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.github.damontecres.wholphin.ui.playback
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
|
|
@ -18,10 +19,15 @@ import androidx.compose.foundation.layout.Spacer
|
|||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -32,6 +38,7 @@ import androidx.compose.runtime.setValue
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
|
|
@ -251,8 +258,34 @@ fun PlaybackOverlay(
|
|||
exit = slideOutVertically { it / 2 } + fadeOut(),
|
||||
) {
|
||||
if (chapters.isNotEmpty()) {
|
||||
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||
val chapterIndex =
|
||||
remember {
|
||||
val position = playerControls.currentPosition.milliseconds
|
||||
val index =
|
||||
chapters
|
||||
.indexOfFirst { it.position > position }
|
||||
.minus(1)
|
||||
.let {
|
||||
if (it < 0) {
|
||||
// Didn't find a chapter, so it's either the first or last
|
||||
if (position < chapters.first().position) {
|
||||
0
|
||||
} else {
|
||||
chapters.lastIndex
|
||||
}
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}.coerceIn(0, chapters.lastIndex)
|
||||
index
|
||||
}
|
||||
val listState = rememberLazyListState(chapterIndex)
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||
LaunchedEffect(Unit) {
|
||||
bringIntoViewRequester.bringIntoView()
|
||||
focusRequester.tryRequestFocus()
|
||||
}
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier =
|
||||
|
|
@ -273,6 +306,7 @@ fun PlaybackOverlay(
|
|||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
LazyRow(
|
||||
state = listState,
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier =
|
||||
|
|
@ -302,9 +336,18 @@ fun PlaybackOverlay(
|
|||
},
|
||||
interactionSource = interactionSource,
|
||||
modifier =
|
||||
Modifier.ifElse(
|
||||
Modifier
|
||||
.ifElse(
|
||||
index == chapterIndex,
|
||||
Modifier
|
||||
.focusRequester(focusRequester)
|
||||
.bringIntoViewRequester(bringIntoViewRequester),
|
||||
).ifElse(
|
||||
index == 0,
|
||||
Modifier.focusRequester(focusRequester),
|
||||
Modifier.focusProperties {
|
||||
// Prevent scrolling left on first card to prevent moving down
|
||||
left = FocusRequester.Cancel
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -443,6 +486,12 @@ fun PlaybackOverlay(
|
|||
text = (seekProgressMs / 1000L).seconds.toString(),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier =
|
||||
Modifier
|
||||
.background(
|
||||
Color.Black.copy(alpha = 0.6f),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
).padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -525,13 +574,22 @@ fun Controller(
|
|||
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
onNextStateFocus: () -> Unit = {},
|
||||
) {
|
||||
val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState()
|
||||
val verticalOffset by animateDpAsState(
|
||||
targetValue = if (seekBarFocused) (-32).dp else 0.dp,
|
||||
label = "TitleBumpOffset",
|
||||
)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 16.dp)
|
||||
.offset(y = verticalOffset),
|
||||
) {
|
||||
title?.let {
|
||||
Text(
|
||||
|
|
|
|||
|
|
@ -86,7 +86,6 @@ import com.github.damontecres.wholphin.util.mpv.MpvPlayer
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration
|
||||
|
|
@ -163,8 +162,7 @@ fun PlaybackPageContent(
|
|||
itemId = UUID.randomUUID(),
|
||||
),
|
||||
)
|
||||
val currentSegment by viewModel.currentSegment.observeAsState(null)
|
||||
var segmentCancelled by remember(currentSegment?.id) { mutableStateOf(false) }
|
||||
val currentSegment by viewModel.currentSegment.collectAsState()
|
||||
|
||||
val cues by viewModel.subtitleCues.observeAsState(listOf())
|
||||
var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) }
|
||||
|
|
@ -302,10 +300,10 @@ fun PlaybackPageContent(
|
|||
}
|
||||
|
||||
val showSegment =
|
||||
!segmentCancelled && currentSegment != null &&
|
||||
currentSegment?.interacted == false &&
|
||||
nextUp == null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L
|
||||
BackHandler(showSegment) {
|
||||
segmentCancelled = true
|
||||
viewModel.updateSegment(currentSegment?.segment?.id, true)
|
||||
}
|
||||
|
||||
Box(
|
||||
|
|
@ -400,7 +398,7 @@ fun PlaybackPageContent(
|
|||
onClickPlaylist = {
|
||||
viewModel.playItemInPlaylist(it)
|
||||
},
|
||||
currentSegment = currentSegment,
|
||||
currentSegment = currentSegment?.segment,
|
||||
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
||||
)
|
||||
}
|
||||
|
|
@ -462,13 +460,12 @@ fun PlaybackPageContent(
|
|||
LaunchedEffect(Unit) {
|
||||
focusRequester.tryRequestFocus()
|
||||
delay(10.seconds)
|
||||
segmentCancelled = true
|
||||
viewModel.updateSegment(segment.segment.id, true)
|
||||
}
|
||||
TextButton(
|
||||
stringRes = segment.type.skipStringRes,
|
||||
stringRes = segment.segment.type.skipStringRes,
|
||||
onClick = {
|
||||
segmentCancelled = true
|
||||
player.seekTo(segment.endTicks.ticks.inWholeMilliseconds)
|
||||
viewModel.updateSegment(segment.segment.id, false)
|
||||
},
|
||||
modifier = Modifier.focusRequester(focusRequester),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior
|
|||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.DatePlayedService
|
||||
import com.github.damontecres.wholphin.services.DeviceProfileService
|
||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.PlayerFactory
|
||||
import com.github.damontecres.wholphin.services.PlaylistCreationResult
|
||||
|
|
@ -49,6 +50,7 @@ import com.github.damontecres.wholphin.services.RefreshRateService
|
|||
import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.onMain
|
||||
|
|
@ -57,7 +59,6 @@ import com.github.damontecres.wholphin.ui.seekForward
|
|||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import com.github.damontecres.wholphin.util.EqualityMutableLiveData
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener
|
||||
|
|
@ -95,6 +96,7 @@ import org.jellyfin.sdk.api.client.extensions.videosApi
|
|||
import org.jellyfin.sdk.api.sockets.subscribe
|
||||
import org.jellyfin.sdk.model.DeviceInfo
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.MediaSegmentDto
|
||||
import org.jellyfin.sdk.model.api.MediaSegmentType
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
|
|
@ -137,6 +139,7 @@ class PlaybackViewModel
|
|||
private val refreshRateService: RefreshRateService,
|
||||
val streamChoiceService: StreamChoiceService,
|
||||
private val userPreferencesService: UserPreferencesService,
|
||||
private val imageUrlService: ImageUrlService,
|
||||
@Assisted private val destination: Destination,
|
||||
) : ViewModel(),
|
||||
Player.Listener,
|
||||
|
|
@ -164,8 +167,7 @@ class PlaybackViewModel
|
|||
val currentMediaInfo = MutableLiveData<CurrentMediaInfo>(CurrentMediaInfo.EMPTY)
|
||||
val currentPlayback = MutableStateFlow<CurrentPlayback?>(null)
|
||||
val currentItemPlayback = MutableLiveData<ItemPlayback>()
|
||||
val currentSegment = EqualityMutableLiveData<MediaSegmentDto?>(null)
|
||||
private val autoSkippedSegments = mutableSetOf<UUID>()
|
||||
val currentSegment = MutableStateFlow<MediaSegmentState?>(null)
|
||||
|
||||
val subtitleCues = MutableLiveData<List<Cue>>(listOf())
|
||||
|
||||
|
|
@ -672,7 +674,15 @@ class PlaybackViewModel
|
|||
MediaItem
|
||||
.Builder()
|
||||
.setMediaId(itemId.toString())
|
||||
.setUri(mediaUrl.toUri())
|
||||
.setMediaMetadata(
|
||||
item.toMediaMetadata(
|
||||
imageUrlService.getItemImageUrl(
|
||||
item,
|
||||
ImageType.PRIMARY,
|
||||
useSeriesForPrimary = true,
|
||||
),
|
||||
),
|
||||
).setUri(mediaUrl.toUri())
|
||||
.setSubtitleConfigurations(listOfNotNull(externalSubtitle))
|
||||
.apply {
|
||||
when (source.container) {
|
||||
|
|
@ -957,15 +967,19 @@ class PlaybackViewModel
|
|||
}
|
||||
}
|
||||
|
||||
// Variables for tracking segment state
|
||||
private var segmentJob: Job? = null
|
||||
private val autoSkippedSegments = mutableSetOf<UUID>()
|
||||
private val outroShownSegments = mutableSetOf<UUID>()
|
||||
|
||||
/**
|
||||
* Cancels listening for segments and clears current segment state
|
||||
*/
|
||||
private suspend fun resetSegmentState() {
|
||||
private fun resetSegmentState() {
|
||||
segmentJob?.cancel()
|
||||
autoSkippedSegments.clear()
|
||||
currentSegment.setValueOnMain(null)
|
||||
outroShownSegments.clear()
|
||||
currentSegment.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -988,8 +1002,13 @@ class PlaybackViewModel
|
|||
it.type != MediaSegmentType.UNKNOWN && currentTicks >= it.startTicks && currentTicks < it.endTicks
|
||||
}
|
||||
if (currentSegment != null &&
|
||||
currentSegment.itemId == this@PlaybackViewModel.itemId &&
|
||||
autoSkippedSegments.add(currentSegment.id)
|
||||
currentSegment.itemId == this@PlaybackViewModel.itemId
|
||||
) {
|
||||
if (currentSegment.id !=
|
||||
this@PlaybackViewModel
|
||||
.currentSegment.value
|
||||
?.segment
|
||||
?.id
|
||||
) {
|
||||
Timber.d(
|
||||
"Found media segment for %s: %s, %s",
|
||||
|
|
@ -997,11 +1016,13 @@ class PlaybackViewModel
|
|||
currentSegment.id,
|
||||
currentSegment.type,
|
||||
)
|
||||
}
|
||||
val playlist = this@PlaybackViewModel.playlist.value
|
||||
|
||||
if (currentSegment.type == MediaSegmentType.OUTRO &&
|
||||
prefs.showNextUpWhen == ShowNextUpWhen.DURING_CREDITS &&
|
||||
playlist != null && playlist.hasNext()
|
||||
playlist != null && playlist.hasNext() &&
|
||||
outroShownSegments.add(currentSegment.id)
|
||||
) {
|
||||
val nextItem = playlist.peek()
|
||||
Timber.v("Setting next up during outro to ${nextItem?.id}")
|
||||
|
|
@ -1021,13 +1042,21 @@ class PlaybackViewModel
|
|||
withContext(Dispatchers.Main) {
|
||||
when (behavior) {
|
||||
SkipSegmentBehavior.AUTO_SKIP -> {
|
||||
this@PlaybackViewModel.currentSegment.value = null
|
||||
player.seekTo(currentSegment.endTicks.ticks.inWholeMilliseconds + 1)
|
||||
if (autoSkippedSegments.add(currentSegment.id)) {
|
||||
onMain { player.seekTo(currentSegment.endTicks.ticks.inWholeMilliseconds + 1) }
|
||||
}
|
||||
this@PlaybackViewModel.currentSegment.update {
|
||||
MediaSegmentState(currentSegment, true)
|
||||
}
|
||||
}
|
||||
|
||||
SkipSegmentBehavior.ASK_TO_SKIP -> {
|
||||
this@PlaybackViewModel.currentSegment.value =
|
||||
currentSegment
|
||||
this@PlaybackViewModel.currentSegment.update {
|
||||
MediaSegmentState(
|
||||
currentSegment,
|
||||
autoSkippedSegments.contains(currentSegment.id),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
|
|
@ -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 =
|
||||
viewModelScope.launchIO {
|
||||
currentPlayback.collectLatest {
|
||||
|
|
@ -1379,3 +1430,8 @@ data class PlayerState(
|
|||
val player: Player,
|
||||
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.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
|
|
@ -51,6 +53,7 @@ import com.github.damontecres.wholphin.preferences.PlayerBackend
|
|||
import com.github.damontecres.wholphin.preferences.advancedPreferences
|
||||
import com.github.damontecres.wholphin.preferences.basicPreferences
|
||||
import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences
|
||||
import com.github.damontecres.wholphin.services.SeerrConnectionStatus
|
||||
import com.github.damontecres.wholphin.services.UpdateChecker
|
||||
import com.github.damontecres.wholphin.ui.components.ConfirmDialog
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
|
|
@ -89,10 +92,10 @@ fun PreferencesContent(
|
|||
val currentServer by seerrVm.currentSeerrServer.collectAsState(null)
|
||||
var showPinFlow by remember { mutableStateOf(false) }
|
||||
|
||||
val navDrawerPins by viewModel.navDrawerPins.observeAsState(mapOf())
|
||||
var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) }
|
||||
val seerrIntegrationEnabled by viewModel.seerrEnabled.collectAsState(false)
|
||||
val seerrConnection by viewModel.seerrConnection.collectAsState()
|
||||
var seerrDialogMode by remember { mutableStateOf<SeerrDialogMode>(SeerrDialogMode.None) }
|
||||
var showQuickConnectDialog by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.preferenceDataStore.data.collect {
|
||||
|
|
@ -165,14 +168,9 @@ fun PreferencesContent(
|
|||
LaunchedEffect(Unit) {
|
||||
focusRequester.tryRequestFocus()
|
||||
}
|
||||
LazyColumn(
|
||||
state = state,
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
Column(
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)),
|
||||
) {
|
||||
stickyHeader {
|
||||
Text(
|
||||
text = stringResource(screenTitle),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
|
|
@ -183,7 +181,13 @@ fun PreferencesContent(
|
|||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
LazyColumn(
|
||||
state = state,
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
if (UpdateChecker.ACTIVE &&
|
||||
preferenceScreenOption == PreferenceScreenOption.BASIC &&
|
||||
preferences.autoCheckForUpdates &&
|
||||
|
|
@ -334,21 +338,12 @@ fun PreferencesContent(
|
|||
}
|
||||
|
||||
AppPreference.UserPinnedNavDrawerItems -> {
|
||||
val selectedItems =
|
||||
navDrawerPins.keys.mapNotNull {
|
||||
if (navDrawerPins[it] ?: false) it else null
|
||||
}
|
||||
MultiChoicePreference(
|
||||
NavDrawerPreference(
|
||||
title = stringResource(pref.title),
|
||||
summary = pref.summary(context, null),
|
||||
possibleValues = navDrawerPins.keys,
|
||||
selectedValues = selectedItems.toSet(),
|
||||
onValueChange = { newSelectedItems ->
|
||||
viewModel.updatePins(newSelectedItems)
|
||||
},
|
||||
) {
|
||||
Text(it.name(context))
|
||||
}
|
||||
modifier = Modifier,
|
||||
interactionSource = interactionSource,
|
||||
)
|
||||
}
|
||||
|
||||
AppPreference.SendAppLogs -> {
|
||||
|
|
@ -394,25 +389,51 @@ fun PreferencesContent(
|
|||
ClickPreference(
|
||||
title = stringResource(pref.title),
|
||||
onClick = {
|
||||
if (seerrIntegrationEnabled) {
|
||||
seerrDialogMode = SeerrDialogMode.Remove
|
||||
} else {
|
||||
seerrVm.resetStatus()
|
||||
seerrDialogMode = SeerrDialogMode.Add
|
||||
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 =
|
||||
if (seerrIntegrationEnabled) {
|
||||
stringResource(R.string.enabled)
|
||||
} else {
|
||||
null
|
||||
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(
|
||||
|
|
@ -456,6 +477,7 @@ fun PreferencesContent(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showPinFlow && currentUser != null) {
|
||||
currentUser?.let { user ->
|
||||
SetPinFlow(
|
||||
|
|
@ -472,11 +494,11 @@ fun PreferencesContent(
|
|||
)
|
||||
}
|
||||
}
|
||||
when (seerrDialogMode) {
|
||||
SeerrDialogMode.Remove -> {
|
||||
when (val mode = seerrDialogMode) {
|
||||
is SeerrDialogMode.Remove -> {
|
||||
ConfirmDialog(
|
||||
title = stringResource(R.string.remove_seerr_server),
|
||||
body = currentServer?.url ?: "",
|
||||
body = mode.serverUrl,
|
||||
onCancel = { seerrDialogMode = SeerrDialogMode.None },
|
||||
onConfirm = {
|
||||
seerrVm.removeServer()
|
||||
|
|
@ -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 -> {}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -542,10 +617,17 @@ data class CacheUsage(
|
|||
val imageDiskUsed: Long,
|
||||
)
|
||||
|
||||
private sealed class SeerrDialogMode {
|
||||
data object None : SeerrDialogMode()
|
||||
private sealed interface SeerrDialogMode {
|
||||
data object None : SeerrDialogMode
|
||||
|
||||
data object Add : SeerrDialogMode()
|
||||
data object Add : SeerrDialogMode
|
||||
|
||||
data object Remove : SeerrDialogMode()
|
||||
data class Remove(
|
||||
val serverUrl: String,
|
||||
) : SeerrDialogMode
|
||||
|
||||
data class Error(
|
||||
val serverUrl: String,
|
||||
val ex: Exception,
|
||||
) : SeerrDialogMode
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,17 +2,10 @@ package com.github.damontecres.wholphin.ui.preferences
|
|||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.asFlow
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.data.NavDrawerItemRepository
|
||||
import com.github.damontecres.wholphin.data.ServerPreferencesDao
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.isPinned
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
|
||||
import com.github.damontecres.wholphin.data.model.NavPinType
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.resetSubtitles
|
||||
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
|
||||
|
|
@ -21,13 +14,13 @@ import com.github.damontecres.wholphin.services.NavigationManager
|
|||
import com.github.damontecres.wholphin.services.SeerrServerRepository
|
||||
import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import com.github.damontecres.wholphin.util.RememberTabManager
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.model.ClientInfo
|
||||
import org.jellyfin.sdk.model.DeviceInfo
|
||||
|
|
@ -44,62 +37,22 @@ class PreferencesViewModel
|
|||
val backdropService: BackdropService,
|
||||
private val rememberTabManager: RememberTabManager,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val navDrawerItemRepository: NavDrawerItemRepository,
|
||||
private val serverPreferencesDao: ServerPreferencesDao,
|
||||
private val seerrServerRepository: SeerrServerRepository,
|
||||
private val deviceInfo: DeviceInfo,
|
||||
private val clientInfo: ClientInfo,
|
||||
) : ViewModel(),
|
||||
RememberTabManager by rememberTabManager {
|
||||
private lateinit var allNavDrawerItems: List<NavDrawerItem>
|
||||
val navDrawerPins = MutableLiveData<Map<NavDrawerItem, Boolean>>(mapOf())
|
||||
|
||||
val currentUser get() = serverRepository.currentUser
|
||||
|
||||
val seerrEnabled =
|
||||
seerrServerRepository.currentUser.combine(currentUser.asFlow()) { seerrUser, jellyfinUser ->
|
||||
seerrUser != null && jellyfinUser != null && seerrUser.jellyfinUserRowId == jellyfinUser.rowId
|
||||
}
|
||||
val seerrConnection = seerrServerRepository.connection
|
||||
|
||||
private val _quickConnectStatus = MutableStateFlow<LoadingState>(LoadingState.Pending)
|
||||
val quickConnectStatus: StateFlow<LoadingState> = _quickConnectStatus
|
||||
|
||||
init {
|
||||
viewModelScope.launchIO {
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
allNavDrawerItems = navDrawerItemRepository.getNavDrawerItems()
|
||||
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)
|
||||
// fetchNavDrawerPins(user)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
suspend fun resetSubtitleSettings(appPreferences: DataStore<AppPreferences>) {
|
||||
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() }
|
||||
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||
Text(
|
||||
text = "Enter URL & API Key",
|
||||
text = stringResource(R.string.enter_url_api_key),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
|
|
@ -73,7 +73,7 @@ fun AddSeerrServerApiKey(
|
|||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
) {
|
||||
Text(
|
||||
text = "URL",
|
||||
text = stringResource(R.string.url),
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
EditTextBox(
|
||||
|
|
@ -104,7 +104,7 @@ fun AddSeerrServerApiKey(
|
|||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
) {
|
||||
Text(
|
||||
text = "API Key",
|
||||
text = stringResource(R.string.api_key),
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
EditTextBox(
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
|
||||
import com.github.damontecres.wholphin.ui.components.BasicDialog
|
||||
import com.github.damontecres.wholphin.ui.components.DialogItem
|
||||
|
|
@ -71,27 +73,26 @@ fun ChooseSeerrLoginType(
|
|||
onChoose: (SeerrAuthMethod) -> Unit,
|
||||
) {
|
||||
val params =
|
||||
remember {
|
||||
DialogParams(
|
||||
fromLongClick = false,
|
||||
title = "Login to Seerr server",
|
||||
title = stringResource(R.string.seerr_login),
|
||||
items =
|
||||
listOf(
|
||||
DialogItem(
|
||||
text = "API Key",
|
||||
text = stringResource(R.string.api_key),
|
||||
onClick = { onChoose.invoke(SeerrAuthMethod.API_KEY) },
|
||||
),
|
||||
DialogItem(
|
||||
text = "Jellyfin user",
|
||||
text = stringResource(R.string.seerr_jellyfin_user),
|
||||
onClick = { onChoose.invoke(SeerrAuthMethod.JELLYFIN) },
|
||||
),
|
||||
DialogItem(
|
||||
text = "Local user",
|
||||
text = stringResource(R.string.seerr_local_user),
|
||||
onClick = { onChoose.invoke(SeerrAuthMethod.LOCAL) },
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
DialogPopup(
|
||||
params = params,
|
||||
onDismissRequest = onDismissRequest,
|
||||
|
|
|
|||
|
|
@ -314,11 +314,11 @@ fun SlideshowPage(
|
|||
) {
|
||||
when (loadingState) {
|
||||
ImageLoadingState.Error -> {
|
||||
ErrorMessage("Error loading image", null)
|
||||
ErrorMessage("Error loading image", null, modifier)
|
||||
}
|
||||
|
||||
ImageLoadingState.Loading -> {
|
||||
LoadingPage()
|
||||
LoadingPage(modifier)
|
||||
}
|
||||
|
||||
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.SERIES,
|
||||
BaseItemKind.VIDEO,
|
||||
BaseItemKind.MUSIC_VIDEO,
|
||||
BaseItemKind.SEASON,
|
||||
BaseItemKind.COLLECTION_FOLDER,
|
||||
BaseItemKind.FOLDER,
|
||||
|
|
@ -35,11 +36,20 @@ val supportedCollectionTypes =
|
|||
null, // Mixed
|
||||
)
|
||||
|
||||
val supportedHomeCollectionTypes =
|
||||
setOf(
|
||||
CollectionType.MOVIES,
|
||||
CollectionType.TVSHOWS,
|
||||
CollectionType.HOMEVIDEOS,
|
||||
null, // Mixed
|
||||
)
|
||||
|
||||
val supportedPlayableTypes =
|
||||
setOf(
|
||||
BaseItemKind.MOVIE,
|
||||
BaseItemKind.EPISODE,
|
||||
BaseItemKind.VIDEO,
|
||||
BaseItemKind.MUSIC_VIDEO,
|
||||
BaseItemKind.TV_CHANNEL,
|
||||
BaseItemKind.TV_PROGRAM,
|
||||
BaseItemKind.RECORDING,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.github.damontecres.wholphin.util
|
||||
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
||||
|
||||
/**
|
||||
* Generic state for loading something from the API
|
||||
|
|
@ -48,6 +49,9 @@ sealed interface RowLoadingState {
|
|||
sealed interface HomeRowLoadingState {
|
||||
val title: String
|
||||
|
||||
val completed: Boolean
|
||||
get() = this is Success || this is Error
|
||||
|
||||
data class Pending(
|
||||
override val title: String,
|
||||
) : HomeRowLoadingState
|
||||
|
|
@ -59,6 +63,7 @@ sealed interface HomeRowLoadingState {
|
|||
data class Success(
|
||||
override val title: String,
|
||||
val items: List<BaseItem?>,
|
||||
val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
|
||||
) : HomeRowLoadingState
|
||||
|
||||
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
|
||||
|
||||
import android.media.MediaCodecInfo
|
||||
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.DlnaProfileType
|
||||
import org.jellyfin.sdk.model.api.EncodingContext
|
||||
|
|
@ -92,9 +94,14 @@ fun createDeviceProfile(
|
|||
val hevcMainLevel = mediaTest.getHevcMainLevel()
|
||||
val hevcMain10Level = mediaTest.getHevcMain10Level()
|
||||
val supportsAVC = mediaTest.supportsAVC()
|
||||
val supportsAVCHigh10 = mediaTest.supportsAVCHigh10()
|
||||
val supportsAVCHigh10 = mediaTest.supportsAVCHigh10() || supportsHi10P52
|
||||
val avcMainLevel = mediaTest.getAVCMainLevel()
|
||||
val avcHigh10Level = mediaTest.getAVCHigh10Level()
|
||||
val avcHigh10Level =
|
||||
if (supportsHi10P52) {
|
||||
MediaCodecInfo.CodecProfileLevel.AVCLevel52
|
||||
} else {
|
||||
mediaTest.getAVCHigh10Level()
|
||||
}
|
||||
val supportsAV1 = mediaTest.supportsAV1()
|
||||
val supportsAV1Main10 = mediaTest.supportsAV1Main10()
|
||||
val supportsVC1 = mediaTest.supportsVc1()
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ package com.github.damontecres.wholphin.util.profile
|
|||
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 =
|
||||
listOf(
|
||||
|
|
@ -14,6 +14,21 @@ private val modelsWithDoViHdr10PlusBug =
|
|||
"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 {
|
||||
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="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>
|
||||
<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>
|
||||
|
|
|
|||
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="continue_watching">Weiterschauen</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="delete">Löschen</string>
|
||||
<string name="died">Gestorben</string>
|
||||
|
|
@ -321,7 +321,7 @@
|
|||
<string name="no">Nein</string>
|
||||
<string name="show_titles">Titel anzeigen</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="play_trailer">Trailer abspielen</string>
|
||||
<string name="local">Lokal</string>
|
||||
|
|
@ -417,4 +417,19 @@
|
|||
<string name="stop_slideshow">Diashow anhalten</string>
|
||||
<string name="brightness">Helligkeit</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>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
<string name="commercial">Comercial</string>
|
||||
<string name="confirm">Confirmar</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="died">Fallecido</string>
|
||||
<string name="enabled">Activado</string>
|
||||
|
|
@ -181,7 +181,7 @@
|
|||
<plurals name="clips">
|
||||
<item quantity="one">Clip</item>
|
||||
<item quantity="many">Clips</item>
|
||||
<item quantity="other"/>
|
||||
<item quantity="other">Clips</item>
|
||||
</plurals>
|
||||
<plurals name="deleted_scenes">
|
||||
<item quantity="one">Escena eliminada</item>
|
||||
|
|
@ -422,4 +422,61 @@
|
|||
<string name="request_4k">Solicitar en 4K</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>
|
||||
<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>
|
||||
|
|
|
|||
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