diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index db31b32e..0b0f2204 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -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
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index b63017b5..86c17c4e 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -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"
diff --git a/README.md b/README.md
index b4121cb9..1b3a0708 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 29c36ac2..366dfcd1 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -7,7 +7,6 @@ import java.util.Properties
plugins {
alias(libs.plugins.android.application)
- alias(libs.plugins.kotlin.android)
alias(libs.plugins.ksp)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.hilt)
@@ -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"
}
}
}
diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/31.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/31.json
new file mode 100644
index 00000000..b719e5f4
--- /dev/null
+++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/31.json
@@ -0,0 +1,642 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 31,
+ "identityHash": "c6829d764ec85321ab3be9905d6c0e3a",
+ "entities": [
+ {
+ "tableName": "servers",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "users",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "rowId",
+ "columnName": "rowId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accessToken",
+ "columnName": "accessToken",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "pin",
+ "columnName": "pin",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "rowId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_users_id_serverId",
+ "unique": true,
+ "columnNames": [
+ "id",
+ "serverId"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)"
+ },
+ {
+ "name": "index_users_id",
+ "unique": false,
+ "columnNames": [
+ "id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)"
+ },
+ {
+ "name": "index_users_serverId",
+ "unique": false,
+ "columnNames": [
+ "serverId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "servers",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "serverId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "ItemPlayback",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "rowId",
+ "columnName": "rowId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "itemId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sourceId",
+ "columnName": "sourceId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "audioIndex",
+ "columnName": "audioIndex",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "subtitleIndex",
+ "columnName": "subtitleIndex",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "rowId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_ItemPlayback_userId_itemId",
+ "unique": true,
+ "columnNames": [
+ "userId",
+ "itemId"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "users",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "userId"
+ ],
+ "referencedColumns": [
+ "rowId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "NavDrawerPinnedItem",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "itemId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "order",
+ "columnName": "order",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "userId",
+ "itemId"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "users",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "userId"
+ ],
+ "referencedColumns": [
+ "rowId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "LibraryDisplayInfo",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "itemId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sort",
+ "columnName": "sort",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "direction",
+ "columnName": "direction",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "filter",
+ "columnName": "filter",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'{}'"
+ },
+ {
+ "fieldPath": "viewOptions",
+ "columnName": "viewOptions",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "userId",
+ "itemId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_LibraryDisplayInfo_userId_itemId",
+ "unique": true,
+ "columnNames": [
+ "userId",
+ "itemId"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "users",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "userId"
+ ],
+ "referencedColumns": [
+ "rowId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "playback_effects",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))",
+ "fields": [
+ {
+ "fieldPath": "jellyfinUserRowId",
+ "columnName": "jellyfinUserRowId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "itemId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "videoFilter.rotation",
+ "columnName": "rotation",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "videoFilter.brightness",
+ "columnName": "brightness",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "videoFilter.contrast",
+ "columnName": "contrast",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "videoFilter.saturation",
+ "columnName": "saturation",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "videoFilter.hue",
+ "columnName": "hue",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "videoFilter.red",
+ "columnName": "red",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "videoFilter.green",
+ "columnName": "green",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "videoFilter.blue",
+ "columnName": "blue",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "videoFilter.blur",
+ "columnName": "blur",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "jellyfinUserRowId",
+ "itemId",
+ "type"
+ ]
+ }
+ },
+ {
+ "tableName": "PlaybackLanguageChoice",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "seriesId",
+ "columnName": "seriesId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "itemId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "audioLanguage",
+ "columnName": "audioLanguage",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "subtitleLanguage",
+ "columnName": "subtitleLanguage",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "subtitlesDisabled",
+ "columnName": "subtitlesDisabled",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "userId",
+ "seriesId"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "users",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "userId"
+ ],
+ "referencedColumns": [
+ "rowId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "ItemTrackModification",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "itemId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "trackIndex",
+ "columnName": "trackIndex",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "delayMs",
+ "columnName": "delayMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "userId",
+ "itemId",
+ "trackIndex"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "users",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "userId"
+ ],
+ "referencedColumns": [
+ "rowId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "seerr_servers",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_seerr_servers_url",
+ "unique": true,
+ "columnNames": [
+ "url"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)"
+ }
+ ]
+ },
+ {
+ "tableName": "seerr_users",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "jellyfinUserRowId",
+ "columnName": "jellyfinUserRowId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "authMethod",
+ "columnName": "authMethod",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "username",
+ "columnName": "username",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "password",
+ "columnName": "password",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "credential",
+ "columnName": "credential",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "jellyfinUserRowId",
+ "serverId"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "seerr_servers",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "serverId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "users",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "jellyfinUserRowId"
+ ],
+ "referencedColumns": [
+ "rowId"
+ ]
+ }
+ ]
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c6829d764ec85321ab3be9905d6c0e3a')"
+ ]
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt
index dcc25af0..b7f4b1d3 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt
@@ -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)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt
index a3629843..b3f33bed 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt
@@ -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)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt
deleted file mode 100644
index a724930d..00000000
--- a/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt
+++ /dev/null
@@ -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 {
- 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): List {
- val user = serverRepository.currentUser.value
- val navDrawerPins =
- user
- ?.let {
- serverPreferencesDao.getNavDrawerPinnedItems(it)
- }.orEmpty()
- val filtered = items.filter { navDrawerPins.isPinned(it.id) }
- if (items.size != filtered.size) {
- // Some were filtered out, check if should include More
- if (navDrawerPins.isPinned(NavDrawerItem.More.id)) {
- return filtered + listOf(NavDrawerItem.More)
- }
- }
- return filtered
- }
- }
-
-fun List.isPinned(id: String) = (firstOrNull { it.itemId == id }?.type ?: NavPinType.PINNED) == NavPinType.PINNED
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt
index f66dcfca..f013525b 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt
@@ -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(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt
index 2d8c7baa..950e9b40 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt
@@ -25,6 +25,7 @@ import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.extensions.ticks
import java.util.Locale
+import java.util.UUID
import kotlin.time.Duration
@Serializable
@@ -32,6 +33,8 @@ import kotlin.time.Duration
data class BaseItem(
val data: BaseItemDto,
val useSeriesForPrimary: Boolean,
+ val imageUrlOverride: String? = null,
+ val destinationOverride: Destination? = null,
) : CardGridItem {
val id get() = data.id
@@ -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?,
+) = Destination.FilteredCollection(
+ itemId = parentId,
+ filter =
+ CollectionFolderFilter(
+ nameOverride =
+ listOfNotNull(
+ genreName,
+ parentName,
+ ).joinToString(" "),
+ filter =
+ GetItemsFilter(
+ genres = listOf(genreId),
+ includeItemTypes = includeItemTypes,
+ ),
+ useSavedLibraryDisplayInfo = false,
+ ),
+ recursive = true,
+)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt
index 1bcacb3f..3444ac54 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt
@@ -31,6 +31,7 @@ data class Chapter(
)
},
)
- }.orEmpty()
+ }?.sortedBy { it.position }
+ .orEmpty()
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt
new file mode 100644
index 00000000..09622733
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt
@@ -0,0 +1,239 @@
+@file:UseSerializers(UUIDSerializer::class)
+
+package com.github.damontecres.wholphin.data.model
+
+import com.github.damontecres.wholphin.preferences.PrefContentScale
+import com.github.damontecres.wholphin.ui.AspectRatio
+import com.github.damontecres.wholphin.ui.Cards
+import com.github.damontecres.wholphin.ui.components.ViewOptionImageType
+import com.github.damontecres.wholphin.ui.data.SortAndDirection
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.UseSerializers
+import org.jellyfin.sdk.model.api.BaseItemKind
+import org.jellyfin.sdk.model.api.request.GetItemsRequest
+import org.jellyfin.sdk.model.serializer.UUIDSerializer
+import java.util.UUID
+
+@Serializable
+sealed interface HomeRowConfig {
+ val viewOptions: HomeRowViewOptions
+
+ fun updateViewOptions(viewOptions: HomeRowViewOptions): HomeRowConfig
+
+ /**
+ * Continue watching media that the user has started but not finished
+ */
+ @Serializable
+ @SerialName("ContinueWatching")
+ data class ContinueWatching(
+ override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
+ ) : HomeRowConfig {
+ override fun updateViewOptions(viewOptions: HomeRowViewOptions): ContinueWatching = this.copy(viewOptions = viewOptions)
+ }
+
+ /**
+ * Next up row for next episodes in a series the user has started
+ */
+ @Serializable
+ @SerialName("NextUp")
+ data class NextUp(
+ override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
+ ) : HomeRowConfig {
+ override fun updateViewOptions(viewOptions: HomeRowViewOptions): NextUp = this.copy(viewOptions = viewOptions)
+ }
+
+ /**
+ * Combined [ContinueWatching] and [NextUp]
+ */
+ @Serializable
+ @SerialName("ContinueWatchingCombined")
+ data class ContinueWatchingCombined(
+ override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
+ ) : HomeRowConfig {
+ override fun updateViewOptions(viewOptions: HomeRowViewOptions): ContinueWatchingCombined = this.copy(viewOptions = viewOptions)
+ }
+
+ /**
+ * Media recently added to a library
+ */
+ @Serializable
+ @SerialName("RecentlyAdded")
+ data class RecentlyAdded(
+ val parentId: UUID,
+ override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
+ ) : HomeRowConfig {
+ override fun updateViewOptions(viewOptions: HomeRowViewOptions): RecentlyAdded = this.copy(viewOptions = viewOptions)
+ }
+
+ /**
+ * Media recently released (premiere date) in a library
+ */
+ @Serializable
+ @SerialName("RecentlyReleased")
+ data class RecentlyReleased(
+ val parentId: UUID,
+ override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
+ ) : HomeRowConfig {
+ override fun updateViewOptions(viewOptions: HomeRowViewOptions): RecentlyReleased = this.copy(viewOptions = viewOptions)
+ }
+
+ /**
+ * Row of a genres in a library
+ */
+ @Serializable
+ @SerialName("Genres")
+ data class Genres(
+ val parentId: UUID,
+ override val viewOptions: HomeRowViewOptions = HomeRowViewOptions.genreDefault,
+ ) : HomeRowConfig {
+ override fun updateViewOptions(viewOptions: HomeRowViewOptions): Genres = this.copy(viewOptions = viewOptions)
+ }
+
+ /**
+ * Favorites for a specific type
+ */
+ @Serializable
+ @SerialName("Favorite")
+ data class Favorite(
+ val kind: BaseItemKind,
+ override val viewOptions: HomeRowViewOptions =
+ if (kind == BaseItemKind.EPISODE) {
+ HomeRowViewOptions(
+ heightDp = Cards.HEIGHT_EPISODE,
+ aspectRatio = AspectRatio.WIDE,
+ )
+ } else {
+ HomeRowViewOptions()
+ },
+ ) : HomeRowConfig {
+ override fun updateViewOptions(viewOptions: HomeRowViewOptions): Favorite = this.copy(viewOptions = viewOptions)
+ }
+
+ /**
+ * Currently recording
+ */
+ @Serializable
+ @SerialName("Recordings")
+ data class Recordings(
+ override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
+ ) : HomeRowConfig {
+ override fun updateViewOptions(viewOptions: HomeRowViewOptions): Recordings = this.copy(viewOptions = viewOptions)
+ }
+
+ /**
+ * Programs on now/recommended
+ */
+ @Serializable
+ @SerialName("TvPrograms")
+ data class TvPrograms(
+ override val viewOptions: HomeRowViewOptions = HomeRowViewOptions.liveTvDefault,
+ ) : HomeRowConfig {
+ override fun updateViewOptions(viewOptions: HomeRowViewOptions): TvPrograms = this.copy(viewOptions = viewOptions)
+ }
+
+ /**
+ * Live TV channels
+ */
+ @Serializable
+ @SerialName("TvChannels")
+ data class TvChannels(
+ override val viewOptions: HomeRowViewOptions = HomeRowViewOptions.liveTvDefault,
+ ) : HomeRowConfig {
+ override fun updateViewOptions(viewOptions: HomeRowViewOptions): TvChannels = this.copy(viewOptions = viewOptions)
+ }
+
+ /**
+ * Fetch suggestions from [com.github.damontecres.wholphin.services.SuggestionService] for the given parent ID
+ */
+ @Serializable
+ @SerialName("Suggestions")
+ data class Suggestions(
+ val parentId: UUID,
+ override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
+ ) : HomeRowConfig {
+ override fun updateViewOptions(viewOptions: HomeRowViewOptions): Suggestions = this.copy(viewOptions = viewOptions)
+ }
+
+ /**
+ * Fetch by parent ID such as a library, collection, or playlist with optional simple sorting
+ */
+ @Serializable
+ @SerialName("ByParent")
+ data class ByParent(
+ val parentId: UUID,
+ val recursive: Boolean,
+ val sort: SortAndDirection? = null,
+ override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
+ ) : HomeRowConfig {
+ override fun updateViewOptions(viewOptions: HomeRowViewOptions): ByParent = this.copy(viewOptions = viewOptions)
+ }
+
+ /**
+ * An arbitrary [GetItemsRequest] allowing to query for anything
+ */
+ @Serializable
+ @SerialName("GetItems")
+ data class GetItems(
+ val name: String,
+ val getItems: GetItemsRequest,
+ override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
+ ) : HomeRowConfig {
+ override fun updateViewOptions(viewOptions: HomeRowViewOptions): GetItems = this.copy(viewOptions = viewOptions)
+ }
+}
+
+/**
+ * Root class for home page settings
+ *
+ * Contains the list of rows and a version
+ */
+@Serializable
+@SerialName("HomePageSettings")
+data class HomePageSettings(
+ val rows: List,
+ val version: Int,
+) {
+ companion object {
+ val EMPTY = HomePageSettings(listOf(), SUPPORTED_HOME_PAGE_SETTINGS_VERSION)
+ }
+}
+
+/**
+ * This is the max version supported by this version of the app
+ */
+const val SUPPORTED_HOME_PAGE_SETTINGS_VERSION = 1
+
+/**
+ * View options for displaying a row
+ *
+ * Allows for changing things like height or aspect ratio
+ */
+@Serializable
+data class HomeRowViewOptions(
+ val heightDp: Int = Cards.HEIGHT_2X3_DP,
+ val spacing: Int = 16,
+ val contentScale: PrefContentScale = PrefContentScale.FILL,
+ val aspectRatio: AspectRatio = AspectRatio.TALL,
+ val imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
+ val showTitles: Boolean = false,
+ val useSeries: Boolean = true,
+ val episodeContentScale: PrefContentScale = PrefContentScale.FILL,
+ val episodeAspectRatio: AspectRatio = AspectRatio.TALL,
+ val episodeImageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
+) {
+ companion object {
+ val genreDefault =
+ HomeRowViewOptions(
+ heightDp = Cards.HEIGHT_EPISODE,
+ aspectRatio = AspectRatio.WIDE,
+ )
+
+ val liveTvDefault =
+ HomeRowViewOptions(
+ heightDp = 96,
+ aspectRatio = AspectRatio.WIDE,
+ contentScale = PrefContentScale.FIT,
+ )
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/ServerPreferences.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/ServerPreferences.kt
index f3f52585..2b503023 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/ServerPreferences.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/ServerPreferences.kt
@@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.data.model
+import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
@@ -24,4 +25,5 @@ data class NavDrawerPinnedItem(
val userId: Int,
val itemId: String,
val type: NavPinType,
+ @ColumnInfo(defaultValue = "-1") val order: Int,
)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt
index 4d7dd76f..709abd6c 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt
@@ -656,6 +656,13 @@ sealed interface AppPreference {
setter = { prefs, _ -> prefs },
)
+ val CustomizeHome =
+ AppDestinationPreference(
+ title = R.string.customize_home,
+ destination = Destination.HomeSettings,
+ summary = R.string.customize_home_summary,
+ )
+
val SendCrashReports =
AppSwitchPreference(
title = R.string.send_crash_reports,
@@ -943,6 +950,14 @@ sealed interface AppPreference {
setter = { prefs, _ -> prefs },
)
+ val QuickConnect =
+ AppClickablePreference(
+ title = R.string.quick_connect,
+ summary = R.string.quick_connect_summary,
+ getter = { },
+ setter = { prefs, _ -> prefs },
+ )
+
val SlideshowDuration =
AppSliderPreference(
title = R.string.slideshow_duration,
@@ -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,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt
index a64fc9d9..e082c5fa 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt
@@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
+import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import timber.log.Timber
import javax.inject.Inject
@@ -47,7 +48,12 @@ class BackdropService
suspend fun submit(item: BaseItem) =
withContext(Dispatchers.IO) {
- val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)!!
+ val imageUrl =
+ if (item.type == BaseItemKind.GENRE) {
+ item.imageUrlOverride
+ } else {
+ imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)!!
+ }
submit(item.id.toString(), imageUrl)
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt
new file mode 100644
index 00000000..b8a850ef
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt
@@ -0,0 +1,1024 @@
+package com.github.damontecres.wholphin.services
+
+import android.content.Context
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.data.ServerRepository
+import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.data.model.HomePageSettings
+import com.github.damontecres.wholphin.data.model.HomeRowConfig
+import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION
+import com.github.damontecres.wholphin.data.model.createGenreDestination
+import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
+import com.github.damontecres.wholphin.preferences.HomePagePreferences
+import com.github.damontecres.wholphin.ui.DefaultItemFields
+import com.github.damontecres.wholphin.ui.SlimItemFields
+import com.github.damontecres.wholphin.ui.components.getGenreImageMap
+import com.github.damontecres.wholphin.ui.main.settings.Library
+import com.github.damontecres.wholphin.ui.main.settings.favoriteOptions
+import com.github.damontecres.wholphin.ui.playback.getTypeFor
+import com.github.damontecres.wholphin.ui.toBaseItems
+import com.github.damontecres.wholphin.ui.toServerString
+import com.github.damontecres.wholphin.util.GetGenresRequestHandler
+import com.github.damontecres.wholphin.util.GetItemsRequestHandler
+import com.github.damontecres.wholphin.util.GetPersonsHandler
+import com.github.damontecres.wholphin.util.HomeRowLoadingState
+import com.github.damontecres.wholphin.util.HomeRowLoadingState.Error
+import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success
+import com.github.damontecres.wholphin.util.supportedHomeCollectionTypes
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.update
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.decodeFromJsonElement
+import kotlinx.serialization.json.encodeToStream
+import kotlinx.serialization.json.intOrNull
+import kotlinx.serialization.json.jsonArray
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import org.jellyfin.sdk.api.client.ApiClient
+import org.jellyfin.sdk.api.client.extensions.displayPreferencesApi
+import org.jellyfin.sdk.api.client.extensions.liveTvApi
+import org.jellyfin.sdk.api.client.extensions.userApi
+import org.jellyfin.sdk.api.client.extensions.userLibraryApi
+import org.jellyfin.sdk.api.client.extensions.userViewsApi
+import org.jellyfin.sdk.model.UUID
+import org.jellyfin.sdk.model.api.BaseItemKind
+import org.jellyfin.sdk.model.api.CollectionType
+import org.jellyfin.sdk.model.api.ImageType
+import org.jellyfin.sdk.model.api.ItemSortBy
+import org.jellyfin.sdk.model.api.SortOrder
+import org.jellyfin.sdk.model.api.UserDto
+import org.jellyfin.sdk.model.api.request.GetGenresRequest
+import org.jellyfin.sdk.model.api.request.GetItemsRequest
+import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
+import org.jellyfin.sdk.model.api.request.GetPersonsRequest
+import org.jellyfin.sdk.model.api.request.GetRecommendedProgramsRequest
+import org.jellyfin.sdk.model.api.request.GetRecordingsRequest
+import timber.log.Timber
+import java.io.File
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class HomeSettingsService
+ @Inject
+ constructor(
+ @param:ApplicationContext private val context: Context,
+ private val api: ApiClient,
+ private val serverRepository: ServerRepository,
+ private val userPreferencesService: UserPreferencesService,
+ private val navDrawerService: NavDrawerService,
+ private val latestNextUpService: LatestNextUpService,
+ private val imageUrlService: ImageUrlService,
+ private val suggestionService: SuggestionService,
+ ) {
+ @OptIn(ExperimentalSerializationApi::class)
+ val jsonParser =
+ Json {
+ isLenient = true
+ ignoreUnknownKeys = true
+ allowTrailingComma = true
+ }
+
+ val currentSettings = MutableStateFlow(HomePageResolvedSettings.EMPTY)
+
+ /**
+ * Saves a [HomePageSettings] to the server for the user under the display preference ID
+ *
+ * @see loadFromServer
+ */
+ suspend fun saveToServer(
+ userId: UUID,
+ settings: HomePageSettings,
+ displayPreferencesId: String = DISPLAY_PREF_ID,
+ ) {
+ val current = getDisplayPreferences(userId, DISPLAY_PREF_ID)
+ val customPrefs =
+ current.customPrefs.toMutableMap().apply {
+ put(CUSTOM_PREF_ID, jsonParser.encodeToString(settings))
+ }
+ api.displayPreferencesApi.updateDisplayPreferences(
+ displayPreferencesId = displayPreferencesId,
+ userId = userId,
+ client = context.getString(R.string.app_name),
+ data = current.copy(customPrefs = customPrefs),
+ )
+ }
+
+ /**
+ * Reads a [HomePageSettings] from the server for the user and display preference ID
+ *
+ * Returns null if there is none saved
+ *
+ * @see saveToServer
+ */
+ suspend fun loadFromServer(
+ userId: UUID,
+ displayPreferencesId: String = DISPLAY_PREF_ID,
+ ): HomePageSettings? {
+ val current = getDisplayPreferences(userId, displayPreferencesId)
+ return current.customPrefs[CUSTOM_PREF_ID]?.let {
+ val jsonElement = jsonParser.parseToJsonElement(it)
+ decode(jsonElement)
+ }
+ }
+
+ private suspend fun getDisplayPreferences(
+ userId: UUID,
+ displayPreferencesId: String,
+ ) = api.displayPreferencesApi
+ .getDisplayPreferences(
+ userId = userId,
+ displayPreferencesId = displayPreferencesId,
+ client = context.getString(R.string.app_name),
+ ).content
+
+ /**
+ * Computes the filename for locally saved [HomePageSettings]
+ */
+ private fun filename(userId: UUID) = "${CUSTOM_PREF_ID}_${userId.toServerString()}.json"
+
+ /**
+ * Save the [HomePageSettings] for the user locally on the device
+ *
+ * @see loadFromLocal
+ */
+ @OptIn(ExperimentalSerializationApi::class)
+ suspend fun saveToLocal(
+ userId: UUID,
+ settings: HomePageSettings,
+ ) {
+ val dir = File(context.filesDir, CUSTOM_PREF_ID)
+ dir.mkdirs()
+ File(dir, filename(userId)).outputStream().use {
+ jsonParser.encodeToStream(settings, it)
+ }
+ }
+
+ /**
+ * Reads [HomePageSettings] for the user if it exists
+ *
+ * @see saveToLocal
+ */
+ @OptIn(ExperimentalSerializationApi::class)
+ suspend fun loadFromLocal(userId: UUID): HomePageSettings? {
+ val dir = File(context.filesDir, CUSTOM_PREF_ID)
+ val file = File(dir, filename(userId))
+ return if (file.exists()) {
+ val fileContents = file.readText()
+ val jsonElement = jsonParser.parseToJsonElement(fileContents)
+ decode(jsonElement)
+ } else {
+ null
+ }
+ }
+
+ /**
+ * Decodes [HomePageSettings] from a [JsonElement] skipping any unknown/unparsable rows
+ *
+ * This is public only for testing
+ */
+ fun decode(element: JsonElement): HomePageSettings {
+ val version = element.jsonObject["version"]?.jsonPrimitive?.intOrNull
+ if (version == null || version > SUPPORTED_HOME_PAGE_SETTINGS_VERSION) {
+ throw UnsupportedHomeSettingsVersionException(version)
+ }
+ val rowsElement = element.jsonObject["rows"]?.jsonArray
+ val rows =
+ rowsElement
+ ?.mapNotNull { row ->
+ try {
+ jsonParser.decodeFromJsonElement(row)
+ } catch (ex: Exception) {
+ Timber.w(ex, "Unknown row %s", row)
+ // TODO maybe use placeholder instead of null?
+ null
+ }
+ }.orEmpty()
+ return HomePageSettings(rows, version)
+ }
+
+ /**
+ * Loads [HomePageSettings] into [currentSettings]
+ *
+ * First checks locally, then on the server, and finally creates a default if needed
+ *
+ * Does not persist either the server nor default
+ */
+ suspend fun loadCurrentSettings(userId: UUID) {
+ Timber.v("Getting setting for %s", userId)
+ // User local then server/remote otherwise create a default
+ val settings =
+ try {
+ val local = loadFromLocal(userId)
+ Timber.v("Found local? %s", local != null)
+ local
+ } catch (ex: Exception) {
+ Timber.w(ex, "Error loading local settings")
+ // TODO show toast?
+ null
+ } ?: try {
+ val remote = loadFromServer(userId)
+ Timber.v("Found remote? %s", remote != null)
+ remote
+ } catch (ex: Exception) {
+ Timber.w(ex, "Error loading remote settings")
+ null
+ }
+ val resolvedSettings =
+ if (settings != null) {
+ Timber.v("Found settings")
+ // Resolve
+ val resolvedRows =
+ settings.rows.mapIndexed { index, config ->
+ resolve(index, config)
+ }
+ HomePageResolvedSettings(resolvedRows)
+ } else {
+ createDefault(userId)
+ }
+
+ currentSettings.update { resolvedSettings }
+ }
+
+ suspend fun updateCurrent(settings: HomePageSettings) {
+ val resolvedRows =
+ settings.rows.mapIndexed { index, config ->
+ resolve(index, config)
+ }
+ val resolvedSettings = HomePageResolvedSettings(resolvedRows)
+ currentSettings.update { resolvedSettings }
+ }
+
+ /**
+ * Create a default [HomePageResolvedSettings] using the available libraries
+ */
+ suspend fun createDefault(userId: UUID): HomePageResolvedSettings {
+ Timber.v("Creating default settings")
+ val user = serverRepository.currentUser.value?.takeIf { it.id == userId }
+ val userDto = serverRepository.currentUserDto.value?.takeIf { it.id == userId }
+ val libraries =
+ if (user != null) {
+ navDrawerService.getFilteredUserLibraries(user, userDto?.tvAccess ?: false)
+ } else {
+ navDrawerService.getAllUserLibraries(userId, userDto?.tvAccess ?: false)
+ }
+
+ val prefs =
+ userPreferencesService.getCurrent().appPreferences.homePagePreferences
+
+ val includedIds =
+ libraries
+ .mapIndexed { index, it ->
+ val parentId = it.itemId
+ val title = getRecentlyAddedTitle(context, it)
+ if (it.collectionType == CollectionType.LIVETV) {
+ HomeRowConfigDisplay(
+ id = index,
+ title = context.getString(R.string.live_tv),
+ config = HomeRowConfig.TvPrograms(),
+ )
+ } else {
+ HomeRowConfigDisplay(
+ id = index,
+ title = title,
+ config = HomeRowConfig.RecentlyAdded(parentId),
+ )
+ }
+ }
+ val continueWatchingRows =
+ if (prefs.combineContinueNext) {
+ listOf(
+ HomeRowConfigDisplay(
+ id = includedIds.size + 1,
+ title = context.getString(R.string.combine_continue_next),
+ config = HomeRowConfig.ContinueWatchingCombined(),
+ ),
+ )
+ } else {
+ listOf(
+ HomeRowConfigDisplay(
+ id = includedIds.size + 1,
+ title = context.getString(R.string.continue_watching),
+ config = HomeRowConfig.ContinueWatching(),
+ ),
+ HomeRowConfigDisplay(
+ id = includedIds.size + 2,
+ title = context.getString(R.string.next_up),
+ config = HomeRowConfig.NextUp(),
+ ),
+ )
+ }
+ val rowConfig = continueWatchingRows + includedIds
+ return HomePageResolvedSettings(rowConfig)
+ }
+
+ suspend fun parseFromWebConfig(userId: UUID): HomePageResolvedSettings? {
+ val customPrefs =
+ api.displayPreferencesApi
+ .getDisplayPreferences(
+ displayPreferencesId = "usersettings",
+ userId = userId,
+ client = "emby",
+ ).content.customPrefs
+ val userDto by api.userApi.getUserById(userId)
+ val config = userDto.configuration ?: DefaultUserConfiguration
+ val libraries =
+ api.userViewsApi
+ .getUserViews(userId = userId)
+ .content.items
+ .filter {
+ it.collectionType in supportedHomeCollectionTypes &&
+ it.id !in config.latestItemsExcludes
+ }
+
+ return if (customPrefs.isNotEmpty()) {
+ var id = 0
+ val rowConfigs =
+ (0..9)
+ .mapNotNull { idx ->
+ val sectionType =
+ HomeSectionType.fromString(customPrefs["homesection$idx"]?.lowercase())
+ Timber.v(
+ "sectionType=$sectionType, %s",
+ customPrefs["homesection$idx"]?.lowercase(),
+ )
+ val config =
+ when (sectionType) {
+ HomeSectionType.ACTIVE_RECORDINGS -> {
+ HomeRowConfigDisplay(
+ id = id++,
+ title = context.getString(R.string.active_recordings),
+ config = HomeRowConfig.Recordings(),
+ )
+ }
+
+ HomeSectionType.RESUME -> {
+ HomeRowConfigDisplay(
+ id = id++,
+ title = context.getString(R.string.continue_watching),
+ config = HomeRowConfig.ContinueWatching(),
+ )
+ }
+
+ HomeSectionType.NEXT_UP -> {
+ HomeRowConfigDisplay(
+ id = id++,
+ title = context.getString(R.string.next_up),
+ config = HomeRowConfig.NextUp(),
+ )
+ }
+
+ HomeSectionType.LIVE_TV -> {
+ if (userDto.tvAccess) {
+ HomeRowConfigDisplay(
+ id = id++,
+ title = context.getString(R.string.live_tv),
+ config = HomeRowConfig.TvPrograms(),
+ )
+ } else {
+ null
+ }
+ }
+
+ HomeSectionType.LATEST_MEDIA -> {
+ // Handled below
+ null
+ }
+
+ // Unsupported
+ HomeSectionType.RESUME_AUDIO,
+ HomeSectionType.RESUME_BOOK,
+ -> {
+ null
+ }
+
+ HomeSectionType.SMALL_LIBRARY_TILES,
+ HomeSectionType.LIBRARY_BUTTONS,
+ HomeSectionType.NONE,
+ null,
+ -> {
+ null
+ }
+ }
+ if (sectionType == HomeSectionType.LATEST_MEDIA) {
+ libraries.map {
+ HomeRowConfigDisplay(
+ id = id++,
+ title =
+ context.getString(
+ R.string.recently_added_in,
+ it.name ?: "",
+ ),
+ config = HomeRowConfig.RecentlyAdded(it.id),
+ )
+ }
+ } else if (config != null) {
+ listOf(config)
+ } else {
+ null
+ }
+ }.flatten()
+ HomePageResolvedSettings(rowConfigs)
+ } else {
+ null
+ }
+ }
+
+ /**
+ * Converts a [HomeRowConfig] into [HomeRowConfigDisplay] for UI purposes
+ */
+ suspend fun resolve(
+ id: Int,
+ config: HomeRowConfig,
+ ): HomeRowConfigDisplay =
+ when (config) {
+ is HomeRowConfig.ByParent -> {
+ val name =
+ api.userLibraryApi
+ .getItem(itemId = config.parentId)
+ .content.name ?: ""
+ HomeRowConfigDisplay(
+ id,
+ name,
+ config,
+ )
+ }
+
+ is HomeRowConfig.ContinueWatching -> {
+ HomeRowConfigDisplay(
+ id,
+ context.getString(R.string.continue_watching),
+ config,
+ )
+ }
+
+ is HomeRowConfig.ContinueWatchingCombined -> {
+ HomeRowConfigDisplay(
+ id,
+ context.getString(R.string.combine_continue_next),
+ config,
+ )
+ }
+
+ is HomeRowConfig.Genres -> {
+ val name =
+ api.userLibraryApi
+ .getItem(itemId = config.parentId)
+ .content.name ?: ""
+ HomeRowConfigDisplay(
+ id,
+ context.getString(R.string.genres_in, name),
+ config,
+ )
+ }
+
+ is HomeRowConfig.GetItems -> {
+ HomeRowConfigDisplay(id, config.name, config)
+ }
+
+ is HomeRowConfig.NextUp -> {
+ HomeRowConfigDisplay(
+ id,
+ context.getString(R.string.next_up),
+ config,
+ )
+ }
+
+ is HomeRowConfig.RecentlyAdded -> {
+ val name =
+ api.userLibraryApi
+ .getItem(itemId = config.parentId)
+ .content.name ?: ""
+ HomeRowConfigDisplay(
+ id,
+ context.getString(R.string.recently_added_in, name),
+ config,
+ )
+ }
+
+ is HomeRowConfig.RecentlyReleased -> {
+ val name =
+ api.userLibraryApi
+ .getItem(itemId = config.parentId)
+ .content.name ?: ""
+ HomeRowConfigDisplay(
+ id,
+ context.getString(R.string.recently_released_in, name),
+ config,
+ )
+ }
+
+ is HomeRowConfig.Favorite -> {
+ val name =
+ context.getString(
+ R.string.favorite_items,
+ context.getString(favoriteOptions[config.kind]!!),
+ )
+ HomeRowConfigDisplay(id, name, config)
+ }
+
+ is HomeRowConfig.Recordings -> {
+ HomeRowConfigDisplay(
+ id = id,
+ title = context.getString(R.string.active_recordings),
+ config,
+ )
+ }
+
+ is HomeRowConfig.TvPrograms -> {
+ HomeRowConfigDisplay(
+ id = id,
+ title = context.getString(R.string.live_tv),
+ config,
+ )
+ }
+
+ is HomeRowConfig.TvChannels -> {
+ HomeRowConfigDisplay(
+ id = id,
+ title = context.getString(R.string.channels),
+ config,
+ )
+ }
+
+ is HomeRowConfig.Suggestions -> {
+ val name =
+ api.userLibraryApi
+ .getItem(itemId = config.parentId)
+ .content.name ?: ""
+ HomeRowConfigDisplay(
+ id = id,
+ title = context.getString(R.string.suggestions_for, name),
+ config,
+ )
+ }
+ }
+
+ /**
+ * Fetch the data from the server for a given [HomeRowConfig]
+ */
+ suspend fun fetchDataForRow(
+ row: HomeRowConfig,
+ scope: CoroutineScope,
+ prefs: HomePagePreferences,
+ userDto: UserDto,
+ libraries: List,
+ limit: Int = prefs.maxItemsPerRow,
+ isRefresh: Boolean,
+ ): HomeRowLoadingState =
+ when (row) {
+ is HomeRowConfig.ContinueWatching -> {
+ val resume =
+ latestNextUpService.getResume(
+ userDto.id,
+ limit,
+ true,
+ row.viewOptions.useSeries,
+ )
+
+ Success(
+ title = context.getString(R.string.continue_watching),
+ items = resume,
+ viewOptions = row.viewOptions,
+ )
+ }
+
+ is HomeRowConfig.NextUp -> {
+ val nextUp =
+ latestNextUpService.getNextUp(
+ userDto.id,
+ limit,
+ prefs.enableRewatchingNextUp,
+ false,
+ prefs.maxDaysNextUp,
+ row.viewOptions.useSeries,
+ )
+
+ Success(
+ title = context.getString(R.string.next_up),
+ items = nextUp,
+ viewOptions = row.viewOptions,
+ )
+ }
+
+ is HomeRowConfig.ContinueWatchingCombined -> {
+ val resume =
+ latestNextUpService.getResume(
+ userDto.id,
+ limit,
+ true,
+ row.viewOptions.useSeries,
+ )
+ val nextUp =
+ latestNextUpService.getNextUp(
+ userDto.id,
+ limit,
+ prefs.enableRewatchingNextUp,
+ false,
+ prefs.maxDaysNextUp,
+ row.viewOptions.useSeries,
+ )
+
+ Success(
+ title = context.getString(R.string.continue_watching),
+ items =
+ latestNextUpService.buildCombined(
+ resume,
+ nextUp,
+ ),
+ viewOptions = row.viewOptions,
+ )
+ }
+
+ is HomeRowConfig.Genres -> {
+ val request =
+ GetGenresRequest(
+ parentId = row.parentId,
+ userId = userDto.id,
+ limit = limit,
+ )
+ val items =
+ GetGenresRequestHandler
+ .execute(api, request)
+ .content.items
+ val genreIds = items.map { it.id }
+ val genreImages =
+ getGenreImageMap(
+ api = api,
+ userId = serverRepository.currentUser.value?.id,
+ scope = scope,
+ imageUrlService = imageUrlService,
+ genres = genreIds,
+ parentId = row.parentId,
+ includeItemTypes = null,
+ cardWidthPx = null,
+ useCache = isRefresh,
+ )
+ val library =
+ libraries
+ .firstOrNull { it.itemId == row.parentId }
+
+ val title =
+ library?.name?.let { context.getString(R.string.genres_in, it) }
+ ?: context.getString(R.string.genres)
+ val genres =
+ items.map {
+ BaseItem(
+ it,
+ false,
+ genreImages[it.id],
+ createGenreDestination(
+ genreId = it.id,
+ genreName = it.name ?: "",
+ parentId = row.parentId,
+ parentName = library?.name,
+ includeItemTypes =
+ library?.collectionType?.let {
+ getTypeFor(it)?.let {
+ listOf(it)
+ }
+ },
+ ),
+ )
+ }
+
+ Success(
+ title,
+ genres,
+ viewOptions = row.viewOptions,
+ )
+ }
+
+ is HomeRowConfig.RecentlyAdded -> {
+ val library =
+ libraries
+ .firstOrNull { it.itemId == row.parentId }
+ val title = getRecentlyAddedTitle(context, library)
+ val request =
+ GetLatestMediaRequest(
+ fields = SlimItemFields,
+ imageTypeLimit = 1,
+ parentId = row.parentId,
+ groupItems = true,
+ limit = limit,
+ isPlayed = null, // Server will handle user's preference
+ )
+ val latest =
+ api.userLibraryApi
+ .getLatestMedia(request)
+ .content
+ .map { BaseItem.Companion.from(it, api, row.viewOptions.useSeries) }
+ .let {
+ Success(
+ title,
+ it,
+ row.viewOptions,
+ )
+ }
+ latest
+ }
+
+ is HomeRowConfig.RecentlyReleased -> {
+ val name =
+ libraries
+ .firstOrNull { it.itemId == row.parentId }
+ ?.name
+ val title =
+ name?.let {
+ context.getString(R.string.recently_released_in, it)
+ } ?: context.getString(R.string.recently_released)
+ val request =
+ GetItemsRequest(
+ parentId = row.parentId,
+ limit = limit,
+ sortBy = listOf(ItemSortBy.PREMIERE_DATE),
+ sortOrder = listOf(SortOrder.DESCENDING),
+ fields = DefaultItemFields,
+ recursive = true,
+ )
+ GetItemsRequestHandler
+ .execute(api, request)
+ .content.items
+ .map { BaseItem.Companion.from(it, api, row.viewOptions.useSeries) }
+ .let {
+ Success(
+ title,
+ it,
+ row.viewOptions,
+ )
+ }
+ }
+
+ is HomeRowConfig.ByParent -> {
+ val request =
+ GetItemsRequest(
+ userId = userDto.id,
+ parentId = row.parentId,
+ recursive = row.recursive,
+ sortBy = row.sort?.let { listOf(it.sort) },
+ sortOrder = row.sort?.let { listOf(it.direction) },
+ limit = limit,
+ fields = DefaultItemFields,
+ )
+ val name =
+ api.userLibraryApi
+ .getItem(itemId = row.parentId)
+ .content.name
+ GetItemsRequestHandler
+ .execute(api, request)
+ .content.items
+ .map { BaseItem(it, row.viewOptions.useSeries) }
+ .let {
+ Success(
+ name ?: context.getString(R.string.collection),
+ it,
+ row.viewOptions,
+ )
+ }
+ }
+
+ is HomeRowConfig.GetItems -> {
+ val request =
+ row.getItems.let {
+ if (it.limit == null) {
+ it.copy(
+ userId = userDto.id,
+ limit = limit,
+ )
+ } else {
+ it.copy(
+ userId = userDto.id,
+ )
+ }
+ }
+ GetItemsRequestHandler
+ .execute(api, request)
+ .content.items
+ .map { BaseItem(it, row.viewOptions.useSeries) }
+ .let {
+ Success(
+ row.name,
+ it,
+ row.viewOptions,
+ )
+ }
+ }
+
+ is HomeRowConfig.Favorite -> {
+ val title =
+ context.getString(
+ R.string.favorite_items,
+ context.getString(favoriteOptions[row.kind]!!),
+ )
+ if (row.kind == BaseItemKind.PERSON) {
+ val request =
+ GetPersonsRequest(
+ userId = userDto.id,
+ limit = limit,
+ fields = DefaultItemFields,
+ isFavorite = true,
+ enableImages = true,
+ enableImageTypes = listOf(ImageType.PRIMARY),
+ )
+ GetPersonsHandler
+ .execute(api, request)
+ .content.items
+ .map { BaseItem(it, true) }
+ .let {
+ Success(
+ title,
+ it,
+ row.viewOptions,
+ )
+ }
+ } else {
+ val request =
+ GetItemsRequest(
+ userId = userDto.id,
+ recursive = true,
+ limit = limit,
+ fields = DefaultItemFields,
+ includeItemTypes = listOf(row.kind),
+ isFavorite = true,
+ )
+ GetItemsRequestHandler
+ .execute(api, request)
+ .content.items
+ .map { BaseItem(it, row.viewOptions.useSeries) }
+ .let {
+ Success(
+ title,
+ it,
+ row.viewOptions,
+ )
+ }
+ }
+ }
+
+ is HomeRowConfig.Recordings -> {
+ val request =
+ GetRecordingsRequest(
+ userId = userDto.id,
+ isInProgress = true,
+ fields = DefaultItemFields,
+ limit = limit,
+ enableImages = true,
+ enableUserData = true,
+ )
+ api.liveTvApi
+ .getRecordings(request)
+ .content.items
+ .map { BaseItem(it, row.viewOptions.useSeries) }
+ .let {
+ Success(
+ context.getString(R.string.active_recordings),
+ it,
+ row.viewOptions,
+ )
+ }
+ }
+
+ is HomeRowConfig.TvPrograms -> {
+ val request =
+ GetRecommendedProgramsRequest(
+ userId = userDto.id,
+ fields = DefaultItemFields,
+ limit = limit,
+ enableUserData = true,
+ enableImages = true,
+ enableImageTypes = listOf(ImageType.PRIMARY),
+ imageTypeLimit = 1,
+ )
+ api.liveTvApi
+ .getRecommendedPrograms(request)
+ .content.items
+ .map { BaseItem(it, row.viewOptions.useSeries) }
+ .let {
+ Success(
+ context.getString(R.string.live_tv),
+ it,
+ row.viewOptions,
+ )
+ }
+ }
+
+ is HomeRowConfig.TvChannels -> {
+ api.liveTvApi
+ .getLiveTvChannels(
+ userId = userDto.id,
+ fields = DefaultItemFields,
+ limit = limit,
+ enableImages = true,
+ ).toBaseItems(api, row.viewOptions.useSeries)
+ .let {
+ Success(
+ context.getString(R.string.channels),
+ it,
+ row.viewOptions,
+ )
+ }
+ }
+
+ is HomeRowConfig.Suggestions -> {
+ val library =
+ api.userLibraryApi
+ .getItem(itemId = row.parentId)
+ .content
+ val title = context.getString(R.string.suggestions_for, library.name ?: "")
+ val itemKind = SuggestionsWorker.getTypeForCollection(library.collectionType)
+ val suggestions =
+ itemKind?.let {
+ suggestionService
+ .getSuggestionsFlow(row.parentId, itemKind)
+ .firstOrNull()
+ }
+ if (suggestions != null && suggestions is SuggestionsResource.Success) {
+ Success(
+ title,
+ suggestions.items,
+ row.viewOptions,
+ )
+ } else if (suggestions is SuggestionsResource.Empty) {
+ Success(
+ title,
+ listOf(),
+ row.viewOptions,
+ )
+ } else {
+ Error(
+ title,
+ message = "Unsupported type ${library.collectionType}",
+ )
+ }
+ }
+ }
+
+ companion object {
+ const val DISPLAY_PREF_ID = "default"
+ const val CUSTOM_PREF_ID = "home_settings"
+ }
+ }
+
+/**
+ * A [HomeRowConfig] with a resolved ID and title so it is usable in the UI
+ */
+data class HomeRowConfigDisplay(
+ val id: Int,
+ val title: String,
+ val config: HomeRowConfig,
+)
+
+/**
+ * List of resolved [HomeRowConfig]s as [HomeRowConfigDisplay]s
+ *
+ * @see HomePageSettings
+ */
+data class HomePageResolvedSettings(
+ val rows: List,
+) {
+ companion object {
+ val EMPTY = HomePageResolvedSettings(listOf())
+ }
+}
+
+// https://github.com/jellyfin/jellyfin/blob/v10.11.6/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/HomeSectionType.cs
+enum class HomeSectionType(
+ val serialName: String,
+) {
+ NONE("none"),
+ SMALL_LIBRARY_TILES("smalllibrarytitles"),
+ LIBRARY_BUTTONS("librarybuttons"),
+ ACTIVE_RECORDINGS("activerecordings"),
+ RESUME("resume"),
+ RESUME_AUDIO("resumeaudio"),
+ LATEST_MEDIA("latestmedia"),
+ NEXT_UP("nextup"),
+ LIVE_TV("livetv"),
+ RESUME_BOOK("resumebook"),
+ ;
+
+ companion object {
+ fun fromString(homeKey: String?) = homeKey?.let { entries.firstOrNull { it.serialName == homeKey } }
+ }
+}
+
+class UnsupportedHomeSettingsVersionException(
+ val unsupportedVersion: Int?,
+ val maxSupportedVersion: Int = SUPPORTED_HOME_PAGE_SETTINGS_VERSION,
+) : Exception("Unsupported version $unsupportedVersion, max supported is $maxSupportedVersion")
+
+fun getRecentlyAddedTitle(
+ context: Context,
+ library: Library?,
+): String =
+ if (library?.isRecordingFolder == true) {
+ context.getString(R.string.recently_recorded)
+ } else {
+ library?.name?.let { context.getString(R.string.recently_added_in, it) }
+ ?: context.getString(R.string.recently_added)
+ }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt
index 39967e24..f0de9d24 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt
@@ -30,6 +30,9 @@ class ImageUrlService
useSeriesForPrimary: Boolean,
imageTags: Map,
imageType: ImageType,
+ parentThumbId: UUID? = null,
+ parentBackdropId: UUID? = null,
+ backdropTags: List = 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,
)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt
index 6ac016a3..e30a39ca 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt
@@ -4,8 +4,6 @@ import android.content.Context
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.SlimItemFields
-import com.github.damontecres.wholphin.ui.main.LatestData
-import com.github.damontecres.wholphin.ui.main.supportedLatestCollectionTypes
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.supportItemKinds
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -21,6 +19,8 @@ import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.BaseItemKind
+import org.jellyfin.sdk.model.api.CollectionType
+import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.UserDto
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
@@ -44,6 +44,7 @@ class LatestNextUpService
userId: UUID,
limit: Int,
includeEpisodes: Boolean,
+ useSeriesForPrimary: Boolean = true,
): List {
val request =
GetResumeItemsRequest(
@@ -60,13 +61,19 @@ class LatestNextUpService
remove(BaseItemKind.EPISODE)
}
},
+ enableImageTypes =
+ listOf(
+ ImageType.PRIMARY,
+ ImageType.THUMB,
+ ImageType.BACKDROP,
+ ),
)
val items =
api.itemsApi
.getResumeItems(request)
.content
.items
- .map { BaseItem.from(it, api, true) }
+ .map { BaseItem.from(it, api, useSeriesForPrimary) }
return items
}
@@ -76,6 +83,7 @@ class LatestNextUpService
enableRewatching: Boolean,
enableResumable: Boolean,
maxDays: Int,
+ useSeriesForPrimary: Boolean = true,
): List {
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,
+)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt
new file mode 100644
index 00000000..adb21ff7
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt
@@ -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 = _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 {
+ val userViews =
+ api.userViewsApi
+ .getUserViews(userId = userId)
+ .content.items
+ val recordingFolders =
+ if (tvAccess) {
+ api.liveTvApi
+ .getRecordingFolders(userId = userId)
+ .content.items
+ .map { it.id }
+ .toSet()
+ } else {
+ setOf()
+ }
+ val libraries =
+ userViews
+ .filter { it.collectionType in supportedCollectionTypes || it.id in recordingFolders }
+ .map {
+ Library(
+ itemId = it.id,
+ name = it.name ?: "",
+ type = it.type,
+ collectionType = it.collectionType ?: CollectionType.UNKNOWN,
+ isRecordingFolder = it.id in recordingFolders,
+ )
+ }
+ return libraries
+ }
+
+ suspend fun getFilteredUserLibraries(
+ user: JellyfinUser,
+ tvAccess: Boolean,
+ ): List {
+ val pins =
+ serverPreferencesDao
+ .getNavDrawerPinnedItems(user)
+ .associateBy { it.itemId }
+ val libraries =
+ getAllUserLibraries(user.id, tvAccess)
+ .filterNot { pins[ServerNavDrawerItem.getId(it.itemId)]?.type == NavPinType.UNPINNED }
+ return libraries
+ }
+
+ suspend fun updateNavDrawer(
+ user: JellyfinUser,
+ userDto: UserDto,
+ ) {
+ val builtins = listOf(NavDrawerItem.Favorites, NavDrawerItem.Discover)
+ val allLibraries = getAllUserLibraries(user.id, userDto.tvAccess)
+ val libraries =
+ allLibraries
+ .map {
+ val destination =
+ if (it.isRecordingFolder) {
+ Destination.Recordings(it.itemId)
+ } else {
+ Destination.MediaItem(
+ it.itemId,
+ it.type,
+ it.collectionType,
+ )
+ }
+ ServerNavDrawerItem(
+ itemId = it.itemId,
+ name = it.name,
+ destination = destination,
+ type = it.collectionType,
+ )
+ }
+ val allItems = builtins + libraries
+
+ val navDrawerPins =
+ serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId }
+
+ val items = mutableListOf()
+ val moreItems = mutableListOf()
+ allItems
+ // Sort by order if non-default, existing items before customize will have -1 value
+ // New items from the server will get Int.MAX_VALUE
+ // Items the user doesn't have access to anymore will be skipped
+ .sortedBy { navDrawerPins[it.id]?.order?.takeIf { it >= 0 } ?: Int.MAX_VALUE }
+ .forEach {
+ // Assume pinned if unknown
+ val pinned = navDrawerPins[it.id]?.type ?: NavPinType.PINNED
+ if (pinned == NavPinType.PINNED) {
+ items.add(it)
+ } else {
+ moreItems.add(it)
+ }
+ }
+
+ _state.update {
+ it.copy(
+ items = items,
+ moreItems = moreItems,
+ )
+ }
+ }
+ }
+
+data class NavDrawerItemState(
+ val items: List,
+ val moreItems: List,
+ val discoverEnabled: Boolean,
+) {
+ companion object {
+ val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false)
+ }
+}
+
+val UserDto.tvAccess: Boolean get() = policy?.enableLiveTvAccess == true
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt
index bc0c8f94..2ca3d1b1 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt
@@ -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 by lazy {
- display.supportedModes
- .orEmpty()
- .map { DisplayMode(it) }
- .sortedWith(
- compareByDescending({ 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({ it.physicalWidth * it.physicalHeight })
+ .thenBy { it.refreshRateRounded },
+ )
+
val currentDisplayMode = display.mode
require(stream.type == MediaStreamType.VIDEO) { "Stream is not video" }
val width = stream.width
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt
index f06a7529..78ff7d3b 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt
@@ -44,18 +44,33 @@ class SeerrServerRepository
private val serverRepository: ServerRepository,
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
) {
- private val _current = MutableStateFlow(null)
- val current: StateFlow = _current
- val currentServer: Flow = current.map { it?.server }
- val currentUser: Flow = current.map { it?.user }
+ private val _connection =
+ MutableStateFlow(SeerrConnectionStatus.NotConfigured)
+ val connection: StateFlow = _connection
+
+ val current: Flow =
+ _connection.map { (it as? SeerrConnectionStatus.Success)?.current }
+ val currentServer: Flow =
+ connection.map { (it as? SeerrConnectionStatus.Success)?.current?.server }
+ val currentUser: Flow =
+ connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user }
/**
* Whether Seerr integration is currently active of not
*/
- val active: Flow = current.map { it != null && seerrApi.active }
+ val active: Flow =
+ connection.map { it is SeerrConnectionStatus.Success && seerrApi.active }
fun clear() {
- _current.update { null }
+ _connection.update { SeerrConnectionStatus.NotConfigured }
+ seerrApi.update("", null)
+ }
+
+ fun error(
+ serverUrl: String,
+ exception: Exception,
+ ) {
+ _connection.update { SeerrConnectionStatus.Error(serverUrl, exception) }
seerrApi.update("", null)
}
@@ -65,8 +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,41 +264,47 @@ class UserSwitchListener
serverRepository.currentUser.asFlow().collect { user ->
Timber.d("New user")
seerrServerRepository.clear()
+ homeSettingsService.currentSettings.update { HomePageResolvedSettings.EMPTY }
if (user != null) {
- seerrServerDao
- .getUsersByJellyfinUser(user.rowId)
- .firstOrNull()
- ?.let { seerrUser ->
- val server = seerrServerDao.getServer(seerrUser.serverId)?.server
- if (server != null) {
- Timber.i("Found a seerr user & server")
- seerrApi.update(server.url, seerrUser.credential)
- val userConfig =
- if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
- try {
- login(
- seerrApi.api,
- seerrUser.authMethod,
- seerrUser.username,
- seerrUser.password,
- )
- } catch (ex: Exception) {
- Timber.w(ex, "Error logging into %s", server.url)
- seerrServerRepository.clear()
- return@let
- }
- } else {
- try {
- seerrApi.api.usersApi.authMeGet()
- } catch (ex: Exception) {
- Timber.w(ex, "Error logging into %s", server.url)
- seerrServerRepository.clear()
- return@let
- }
+ // Check for home settings
+ launchIO {
+ homeSettingsService.loadCurrentSettings(user.id)
+ }
+ // Check for seerr server
+ launchIO {
+ seerrServerDao
+ .getUsersByJellyfinUser(user.rowId)
+ .lastOrNull()
+ ?.let { seerrUser ->
+ val server =
+ seerrServerDao.getServer(seerrUser.serverId)?.server
+ if (server != null) {
+ Timber.i("Found a seerr user & server")
+ try {
+ seerrApi.update(server.url, seerrUser.credential)
+ val userConfig =
+ if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
+ login(
+ seerrApi.api,
+ seerrUser.authMethod,
+ seerrUser.username,
+ seerrUser.password,
+ )
+ } else {
+ seerrApi.api.usersApi.authMeGet()
+ }
+ seerrServerRepository.set(server, seerrUser, userConfig)
+ } catch (ex: Exception) {
+ Timber.w(
+ ex,
+ "Error logging into %s",
+ server.url,
+ )
+ seerrServerRepository.error(server.url, ex)
}
- seerrServerRepository.set(server, seerrUser, userConfig)
+ }
}
- }
+ }
}
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt
index db6cef2c..0fe32179 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt
@@ -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)
+ }
+ }
}
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt
index 5542ea51..0921a1be 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt
@@ -60,15 +60,20 @@ class SuggestionsCache
): CachedSuggestions? {
val key = cacheKey(userId, libraryId, itemKind)
return memoryCache.getOrPut(key) {
- try {
- mutex.withLock {
- File(cacheDir, "$key.json").inputStream().use {
+ mutex.withLock {
+ try {
+ val cacheFile = File(cacheDir, "$key.json")
+ if (!cacheFile.exists()) {
+ return@withLock null
+ }
+
+ cacheFile.inputStream().use {
json.decodeFromStream(it)
}
+ } catch (ex: Exception) {
+ Timber.e(ex, "Exception reading from disk cache")
+ null
}
- } catch (ex: Exception) {
- Timber.e(ex, "Exception reading from disk cache")
- null
}
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt
index 1ee5870b..eab87f20 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt
@@ -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
+ }
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt
index f207562c..52c37413 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt
@@ -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(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt
index d8816e71..03ea0478 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt
@@ -80,7 +80,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 {
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 =
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt
index 12854cd7..c997efd1 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt
@@ -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).
*
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt
index 1842620a..8f4066bb 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt
@@ -5,6 +5,7 @@ import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.WholphinApplication
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.MediaSegmentType
import timber.log.Timber
@@ -76,7 +77,7 @@ val BaseItemDto.seriesProductionYears: String?
append(productionYear.toString())
if (status == "Continuing") {
append(" - ")
- append("Present")
+ append(WholphinApplication.instance.getString(R.string.series_continueing))
} else if (status == "Ended") {
endDate?.let {
if (it.year != productionYear) {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt
index 958f9dfd..2d9b6d5e 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt
@@ -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
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt
index 70de419b..51d003fe 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt
@@ -1,15 +1,19 @@
package com.github.damontecres.wholphin.ui.cards
+import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -24,6 +28,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@@ -41,6 +46,7 @@ import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.LocalImageUrlService
+import com.github.damontecres.wholphin.ui.enableMarquee
import org.jellyfin.sdk.model.api.ImageType
/**
@@ -60,6 +66,9 @@ fun BannerCard(
cardHeight: Dp = 120.dp,
aspectRatio: Float = AspectRatios.WIDE,
interactionSource: MutableInteractionSource? = null,
+ imageType: ImageType = ImageType.PRIMARY,
+ imageContentScale: ContentScale = ContentScale.FillBounds,
+ useSeriesForPrimary: Boolean = true,
) {
val imageUrlService = LocalImageUrlService.current
val density = LocalDensity.current
@@ -74,19 +83,21 @@ fun BannerCard(
}
}
val imageUrl =
- remember(item, fillHeight) {
+ remember(item, fillHeight, imageType, useSeriesForPrimary) {
if (item != null) {
- imageUrlService.getItemImageUrl(
- item,
- ImageType.PRIMARY,
- fillWidth = null,
- fillHeight = fillHeight,
- )
+ item.imageUrlOverride
+ ?: imageUrlService.getItemImageUrl(
+ item,
+ imageType,
+ fillWidth = null,
+ fillHeight = fillHeight,
+ useSeriesForPrimary = useSeriesForPrimary,
+ )
} else {
null
}
}
- var imageError by remember { mutableStateOf(false) }
+ var imageError by remember(imageUrl) { mutableStateOf(false) }
Card(
modifier = modifier.size(cardHeight * aspectRatio, cardHeight),
onClick = onClick,
@@ -107,7 +118,7 @@ fun BannerCard(
AsyncImage(
model = imageUrl,
contentDescription = null,
- contentScale = ContentScale.FillBounds,
+ contentScale = imageContentScale,
onError = { imageError = true },
modifier = Modifier.fillMaxSize(),
)
@@ -115,7 +126,7 @@ fun BannerCard(
Text(
text = name ?: "",
color = MaterialTheme.colorScheme.onSurfaceVariant,
- style = MaterialTheme.typography.titleLarge,
+ style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
modifier =
Modifier
@@ -181,3 +192,84 @@ fun BannerCard(
}
}
}
+
+@Composable
+fun BannerCardWithTitle(
+ title: String?,
+ subtitle: String?,
+ item: BaseItem?,
+ onClick: () -> Unit,
+ onLongClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ cornerText: String? = null,
+ played: Boolean = false,
+ favorite: Boolean = false,
+ playPercent: Double = 0.0,
+ cardHeight: Dp = 120.dp,
+ aspectRatio: Float = AspectRatios.WIDE,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+ imageType: ImageType = ImageType.PRIMARY,
+ imageContentScale: ContentScale = ContentScale.FillBounds,
+ useSeriesForPrimary: Boolean = item?.useSeriesForPrimary ?: true,
+) {
+ val focused by interactionSource.collectIsFocusedAsState()
+ val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
+ val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
+ val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
+ val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
+ val width = cardHeight * aspectRationToUse
+ Column(
+ verticalArrangement = Arrangement.spacedBy(spaceBetween),
+ modifier = modifier.width(width),
+ ) {
+ BannerCard(
+ name = null,
+ item = item,
+ onClick = onClick,
+ onLongClick = onLongClick,
+ modifier = Modifier,
+ cornerText = cornerText,
+ played = played,
+ favorite = favorite,
+ playPercent = playPercent,
+ cardHeight = cardHeight,
+ aspectRatio = aspectRatio,
+ interactionSource = interactionSource,
+ imageType = imageType,
+ imageContentScale = imageContentScale,
+ useSeriesForPrimary = useSeriesForPrimary,
+ )
+ Column(
+ verticalArrangement = Arrangement.spacedBy(0.dp),
+ modifier =
+ Modifier
+ .padding(bottom = spaceBelow)
+ .fillMaxWidth(),
+ ) {
+ Text(
+ text = title ?: "",
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ textAlign = TextAlign.Center,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 4.dp)
+ .enableMarquee(focusedAfterDelay),
+ )
+ Text(
+ text = subtitle ?: "",
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Normal,
+ maxLines = 1,
+ textAlign = TextAlign.Center,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 4.dp)
+ .enableMarquee(focusedAfterDelay),
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt
index 4e1d0e86..a7df0cb3 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt
@@ -27,6 +27,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@@ -183,6 +184,8 @@ fun DiscoverItemCard(
text = item?.title ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.SemiBold,
modifier =
Modifier
.fillMaxWidth()
@@ -193,6 +196,8 @@ fun DiscoverItemCard(
text = item?.releaseDate?.year?.toString() ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = FontWeight.Normal,
modifier =
Modifier
.fillMaxWidth()
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt
index e4ffa5ee..279ca7b5 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt
@@ -22,11 +22,13 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
+import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.AppColors
@@ -130,6 +132,8 @@ fun EpisodeCard(
text = dto?.seriesName ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.SemiBold,
modifier =
Modifier
.fillMaxWidth()
@@ -140,6 +144,8 @@ fun EpisodeCard(
text = item?.name ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = FontWeight.Normal,
modifier =
Modifier
.fillMaxWidth()
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt
index 73cf5ba3..a32fece9 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt
@@ -42,11 +42,29 @@ fun GenreCard(
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+) = GenreCard(
+ genreId = genre?.id,
+ name = genre?.name,
+ imageUrl = genre?.imageUrl,
+ onClick = onClick,
+ onLongClick = onLongClick,
+ modifier = modifier,
+ interactionSource = interactionSource,
+)
+
+@Composable
+fun GenreCard(
+ genreId: UUID?,
+ name: String?,
+ imageUrl: String?,
+ onClick: () -> Unit,
+ onLongClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
- val background = rememberIdColor(genre?.id).copy(alpha = .6f)
+ val background = rememberIdColor(genreId).copy(alpha = .6f)
Card(
- modifier =
- modifier,
+ modifier = modifier,
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
@@ -63,12 +81,12 @@ fun GenreCard(
.fillMaxSize()
.clip(RoundedCornerShape(8.dp)),
) {
- if (genre?.imageUrl.isNotNullOrBlank()) {
+ if (imageUrl != null) {
AsyncImage(
model =
ImageRequest
.Builder(LocalContext.current)
- .data(genre.imageUrl)
+ .data(imageUrl)
.crossfade(true)
.build(),
contentScale = ContentScale.FillBounds,
@@ -88,7 +106,7 @@ fun GenreCard(
.background(background),
) {
Text(
- text = genre?.name ?: "",
+ text = name ?: "",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
@@ -112,7 +130,6 @@ private fun GenreCardPreview() {
UUID.randomUUID(),
"Adventure",
null,
- Color.Black,
)
GenreCard(
genre = genre,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt
index 7b653865..c191a70d 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt
@@ -19,6 +19,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -113,6 +114,8 @@ fun GridCard(
text = item?.title ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.SemiBold,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
@@ -124,6 +127,8 @@ fun GridCard(
text = item?.subtitle ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = FontWeight.Normal,
modifier =
Modifier
.fillMaxWidth()
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt
index 7ebf0faf..314a7e1a 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -57,7 +58,7 @@ fun ItemRow(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
- modifier = Modifier,
+ modifier = Modifier.padding(start = 8.dp),
)
LazyRow(
state = state,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt
index 47fd4e94..2ce33e09 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt
@@ -26,6 +26,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -174,6 +175,8 @@ fun PersonCard(
text = name ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.SemiBold,
modifier =
Modifier
.fillMaxWidth()
@@ -185,6 +188,8 @@ fun PersonCard(
text = role,
maxLines = 1,
textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = FontWeight.Normal,
modifier =
Modifier
.fillMaxWidth()
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt
index e3e6059e..b066d372 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt
@@ -16,15 +16,16 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
+import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.AspectRatios
@@ -126,21 +127,7 @@ fun SeasonCard(
val focused by interactionSource.collectIsFocusedAsState()
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
- var focusedAfterDelay by remember { mutableStateOf(false) }
-
- val hideOverlayDelay = 500L
- if (focused) {
- LaunchedEffect(Unit) {
- delay(hideOverlayDelay)
- if (focused) {
- focusedAfterDelay = true
- } else {
- focusedAfterDelay = false
- }
- }
- } else {
- focusedAfterDelay = false
- }
+ val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
val width = imageHeight * aspectRationToUse
val height = imageWidth * (1f / aspectRationToUse)
@@ -193,6 +180,8 @@ fun SeasonCard(
text = title ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.SemiBold,
modifier =
Modifier
.fillMaxWidth()
@@ -203,6 +192,8 @@ fun SeasonCard(
text = subtitle ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = FontWeight.Normal,
modifier =
Modifier
.fillMaxWidth()
@@ -212,3 +203,22 @@ fun SeasonCard(
}
}
}
+
+/**
+ * Returns a [androidx.compose.runtime.State] which represents if the item has been focused for a while
+ */
+@Composable
+fun rememberFocusedAfterDelay(interactionSource: MutableInteractionSource): androidx.compose.runtime.State {
+ val focused by interactionSource.collectIsFocusedAsState()
+ val state = remember { mutableStateOf(false) }
+
+ LaunchedEffect(focused) {
+ if (!focused) {
+ state.value = false
+ return@LaunchedEffect
+ }
+ delay(500L)
+ state.value = true
+ }
+ return state
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt
index 6d20211f..4a71404a 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt
@@ -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,9 +166,10 @@ class CollectionFolderViewModel
viewModelScope.launchIO {
super.itemId = itemId
try {
- itemId.toUUIDOrNull()?.let {
- fetchItem(it)
- }
+ val item =
+ itemId.toUUIDOrNull()?.let {
+ fetchItem(it)
+ }
val libraryDisplayInfo =
serverRepository.currentUser.value?.let { user ->
@@ -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,34 +266,32 @@ class CollectionFolderViewModel
recursive: Boolean,
filter: GetItemsFilter,
useSeriesForPrimary: Boolean,
- ) {
- viewModelScope.launch(Dispatchers.IO) {
- withContext(Dispatchers.Main) {
- if (resetState) {
- loading.value = DataLoadingState.Loading
- }
- backgroundLoading.value = LoadingState.Loading
- this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection
- this@CollectionFolderViewModel.filter.value = filter
+ ) = viewModelScope.launch(Dispatchers.IO) {
+ withContext(Dispatchers.Main) {
+ if (resetState) {
+ loading.value = DataLoadingState.Loading
}
- try {
- val newPager =
- createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init()
- if (newPager.isNotEmpty()) newPager.getBlocking(0)
- withContext(Dispatchers.Main) {
- loading.value = DataLoadingState.Success(newPager)
- backgroundLoading.value = LoadingState.Success
- }
- } catch (ex: Exception) {
- Timber.e(
- ex,
- "Exception while loading data: sort=%s, filter=%s",
- sortAndDirection,
- filter,
- )
- withContext(Dispatchers.Main) {
- loading.value = DataLoadingState.Error(ex)
- }
+ backgroundLoading.value = LoadingState.Loading
+ this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection
+ this@CollectionFolderViewModel.filter.value = filter
+ }
+ try {
+ val newPager =
+ createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init()
+ if (newPager.isNotEmpty()) newPager.getBlocking(0)
+ withContext(Dispatchers.Main) {
+ loading.value = DataLoadingState.Success(newPager)
+ backgroundLoading.value = LoadingState.Success
+ }
+ } catch (ex: Exception) {
+ Timber.e(
+ ex,
+ "Exception while loading data: sort=%s, filter=%s",
+ sortAndDirection,
+ filter,
+ )
+ withContext(Dispatchers.Main) {
+ loading.value = DataLoadingState.Error(ex)
}
}
}
@@ -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(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt
index d5897a6b..579d9a97 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt
@@ -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,
)
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/FocusableItemRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/FocusableItemRow.kt
new file mode 100644
index 00000000..b04bf451
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/FocusableItemRow.kt
@@ -0,0 +1,78 @@
+package com.github.damontecres.wholphin.ui.components
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsFocusedAsState
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.NonRestartableComposable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+
+/**
+ * Placeholder for [com.github.damontecres.wholphin.ui.cards.ItemRow]. It is [focusable] so it can be scrolled.
+ */
+@Composable
+@NonRestartableComposable
+fun FocusableItemRow(
+ title: String,
+ subtitle: String,
+ modifier: Modifier = Modifier,
+ isError: Boolean = false,
+) = FocusableItemRow(
+ titleContent = {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onBackground,
+ )
+ },
+ subtitleContent = {
+ Text(
+ text = subtitle,
+ style = MaterialTheme.typography.titleMedium,
+ color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onBackground,
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ },
+ modifier = modifier,
+)
+
+@Composable
+fun FocusableItemRow(
+ titleContent: @Composable () -> Unit,
+ subtitleContent: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ val focused by interactionSource.collectIsFocusedAsState()
+ val background by animateColorAsState(
+ if (focused) {
+ MaterialTheme.colorScheme.border.copy(alpha = .25f)
+ } else {
+ Color.Unspecified
+ },
+ )
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier =
+ modifier
+ .padding(start = 8.dp)
+ .focusable(interactionSource = interactionSource)
+ .background(background, shape = RoundedCornerShape(8.dp))
+ .padding(8.dp),
+ ) {
+ titleContent.invoke()
+ subtitleContent.invoke()
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt
index cdf18969..e1e28f0c 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt
@@ -5,12 +5,12 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
@@ -21,8 +21,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
-import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
-import com.github.damontecres.wholphin.data.model.GetItemsFilter
+import com.github.damontecres.wholphin.data.model.createGenreDestination
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
@@ -30,17 +29,18 @@ import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.cards.GenreCard
import com.github.damontecres.wholphin.ui.detail.CardGrid
import com.github.damontecres.wholphin.ui.detail.CardGridItem
-import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
+import com.mayakapps.kache.InMemoryKache
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
@@ -56,8 +56,10 @@ import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.request.GetGenresRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
+import timber.log.Timber
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
+import kotlin.time.Duration.Companion.hours
@HiltViewModel(assistedFactory = GenreViewModel.Factory::class)
class GenreViewModel
@@ -102,51 +104,23 @@ class GenreViewModel
.execute(api, request)
.content.items
.map {
- Genre(it.id, it.name ?: "", null, Color.Black)
+ Genre(it.id, it.name ?: "", null)
}
withContext(Dispatchers.Main) {
this@GenreViewModel.genres.value = genres
loading.value = LoadingState.Success
}
- val genreToUrl = ConcurrentHashMap()
- val semaphore = Semaphore(4)
- genres
- .map { genre ->
- viewModelScope.async(Dispatchers.IO) {
- semaphore.withPermit {
- val item =
- GetItemsRequestHandler
- .execute(
- api,
- GetItemsRequest(
- parentId = itemId,
- recursive = true,
- limit = 1,
- sortBy = listOf(ItemSortBy.RANDOM),
- fields = listOf(ItemFields.GENRES),
- imageTypes = listOf(ImageType.BACKDROP),
- imageTypeLimit = 1,
- includeItemTypes = includeItemTypes,
- genreIds = listOf(genre.id),
- enableTotalRecordCount = false,
- ),
- ).content.items
- .firstOrNull()
- if (item != null) {
- genreToUrl[genre.id] =
- imageUrlService.getItemImageUrl(
- itemId = item.id,
- itemType = item.type,
- seriesId = null,
- useSeriesForPrimary = true,
- imageType = ImageType.BACKDROP,
- imageTags = item.imageTags.orEmpty(),
- fillWidth = cardWidthPx,
- )
- }
- }
- }
- }.awaitAll()
+ val genreToUrl =
+ getGenreImageMap(
+ api = api,
+ userId = serverRepository.currentUser.value?.id,
+ scope = viewModelScope,
+ imageUrlService = imageUrlService,
+ genres = genres.map { it.id },
+ parentId = itemId,
+ includeItemTypes = includeItemTypes,
+ cardWidthPx = cardWidthPx,
+ )
val genresWithImages =
genres.map {
it.copy(
@@ -171,11 +145,84 @@ class GenreViewModel
}
}
+data class GenreCacheKey(
+ val userId: UUID?,
+ val parentId: UUID,
+)
+
+private val genreCache by lazy {
+ InMemoryKache>(8) {
+ expireAfterWriteDuration = 2.hours
+ }
+}
+
+suspend fun getGenreImageMap(
+ api: ApiClient,
+ userId: UUID?,
+ scope: CoroutineScope,
+ imageUrlService: ImageUrlService,
+ genres: List,
+ parentId: UUID,
+ includeItemTypes: List?,
+ cardWidthPx: Int?,
+ useCache: Boolean = true,
+): Map {
+ val key = GenreCacheKey(userId, parentId)
+ if (useCache) {
+ genreCache.getIfAvailable(key)?.let {
+ Timber.v("Got cached entry")
+ return it
+ }
+ }
+ val genreToUrl = ConcurrentHashMap()
+ val semaphore = Semaphore(4)
+ genres
+ .map { genreId ->
+ scope.async(Dispatchers.IO) {
+ semaphore.withPermit {
+ val item =
+ GetItemsRequestHandler
+ .execute(
+ api,
+ GetItemsRequest(
+ userId = userId,
+ parentId = parentId,
+ recursive = true,
+ limit = 1,
+ sortBy = listOf(ItemSortBy.RANDOM),
+ fields = listOf(ItemFields.GENRES),
+ imageTypes = listOf(ImageType.BACKDROP),
+ imageTypeLimit = 1,
+ includeItemTypes = includeItemTypes,
+ genreIds = listOf(genreId),
+ enableTotalRecordCount = false,
+ ),
+ ).content.items
+ .firstOrNull()
+ if (item != null) {
+ genreToUrl[genreId] =
+ imageUrlService.getItemImageUrl(
+ itemId = item.id,
+ itemType = item.type,
+ seriesId = null,
+ useSeriesForPrimary = true,
+ imageType = ImageType.BACKDROP,
+ imageTags = item.imageTags.orEmpty(),
+ fillWidth = cardWidthPx,
+ )
+ }
+ }
+ }
+ }.awaitAll()
+ genreCache.put(key, genreToUrl)
+ return genreToUrl
+}
+
+@Stable
data class Genre(
val id: UUID,
val name: String,
val imageUrl: String?,
- val color: Color,
) : CardGridItem {
override val gridId: String get() = id.toString()
override val playable: Boolean = false
@@ -233,23 +280,12 @@ fun GenreCardGrid(
pager = genres,
onClickItem = { _, genre ->
viewModel.navigationManager.navigateTo(
- Destination.FilteredCollection(
- itemId = itemId,
- filter =
- CollectionFolderFilter(
- nameOverride =
- listOfNotNull(
- genre.name,
- item?.title,
- ).joinToString(" "),
- filter =
- GetItemsFilter(
- genres = listOf(genre.id),
- includeItemTypes = includeItemTypes,
- ),
- useSavedLibraryDisplayInfo = false,
- ),
- recursive = true,
+ createGenreDestination(
+ genreId = genre.id,
+ genreName = genre.name,
+ parentId = itemId,
+ parentName = item?.title,
+ includeItemTypes = includeItemTypes,
),
)
},
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt
index 1e274de6..38591a67 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt
@@ -95,13 +95,13 @@ fun ItemGrid(
val items by viewModel.items.observeAsState(listOf())
when (val state = loading) {
is LoadingState.Error -> {
- ErrorMessage(state)
+ ErrorMessage(state, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
-> {
- LoadingPage()
+ LoadingPage(modifier)
}
LoadingState.Success -> {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt
index b0e86e3e..908574e3 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt
@@ -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,
- ) {
- viewModelScope.launch(Dispatchers.IO) {
+ ): Deferred =
+ viewModelScope.async(Dispatchers.IO) {
val titleStr = context.getString(title)
val row =
try {
@@ -115,10 +116,7 @@ abstract class RecommendedViewModel(
HomeRowLoadingState.Error(titleStr, null, ex)
}
update(title, row)
- // TODO
- loading.setValueOnMain(LoadingState.Success)
}
- }
}
@Composable
@@ -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,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt
index e44c72f7..403ff5fa 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt
@@ -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>()
+
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,8 +222,17 @@ class RecommendedMovieViewModel
}
}
+ // If the continue watching row is empty, then wait until the first successful row
+ // is loaded before telling the UI that the page is loaded
if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) {
- loading.setValueOnMain(LoadingState.Success)
+ for (i in 0..
current.toMutableList().apply { set(rowTitles[title]!!, row) }
}
+ return row
}
companion object {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt
index 4e952033..3258930c 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt
@@ -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>()
+
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,7 +270,14 @@ class RecommendedTvShowViewModel
}
if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) {
- loading.setValueOnMain(LoadingState.Success)
+ for (i in 0..
current.toMutableList().apply { set(rowTitles[title]!!, row) }
}
+ return row
}
companion object {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt
index 8893246c..ac72a144 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt
@@ -4,6 +4,8 @@ import androidx.annotation.StringRes
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup
+import androidx.compose.foundation.gestures.BringIntoViewSpec
+import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
@@ -24,6 +26,7 @@ import androidx.compose.foundation.lazy.layout.LazyLayoutCacheWindow
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
@@ -112,6 +115,7 @@ fun CardGrid(
},
columns: Int = 6,
spacing: Dp = 16.dp,
+ bringIntoViewSpec: BringIntoViewSpec = LocalBringIntoViewSpec.current,
) {
val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0))
@@ -269,100 +273,102 @@ fun CardGrid(
Box(
modifier = Modifier.weight(1f),
) {
- LazyVerticalGrid(
- columns = GridCells.Fixed(columns),
- horizontalArrangement = Arrangement.spacedBy(spacing),
- verticalArrangement = Arrangement.spacedBy(spacing),
- state = gridState,
- contentPadding = PaddingValues(vertical = 16.dp),
- modifier =
- Modifier
- .fillMaxSize()
- .focusGroup()
- .focusRestorer(firstFocus)
- .focusProperties {
- onExit = {
- // Leaving the grid, so "forget" the position
+ CompositionLocalProvider(LocalBringIntoViewSpec provides bringIntoViewSpec) {
+ LazyVerticalGrid(
+ columns = GridCells.Fixed(columns),
+ horizontalArrangement = Arrangement.spacedBy(spacing),
+ verticalArrangement = Arrangement.spacedBy(spacing),
+ state = gridState,
+ contentPadding = PaddingValues(vertical = 16.dp),
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .focusGroup()
+ .focusRestorer(firstFocus)
+ .focusProperties {
+ onExit = {
+ // Leaving the grid, so "forget" the position
// focusedIndex = -1
- }
- onEnter = {
- if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) {
- focusedIndex = startPosition
}
- }
- },
- ) {
- items(pager.size) { index ->
- val mod =
- if ((index == focusedIndex) or (focusedIndex < 0 && index == 0)) {
- if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index")
- Modifier
- .focusRequester(firstFocus)
- .focusRequester(gridFocusRequester)
- .focusRequester(alphabetFocusRequester)
- } else {
- Modifier
- }
- val item = pager[index]
- cardContent(
- item,
- {
- if (item != null) {
- focusedIndex = index
- onClickItem.invoke(index, item)
- }
- },
- { if (item != null) onLongClickItem.invoke(index, item) },
- mod
- .ifElse(index == 0, Modifier.focusRequester(zeroFocus))
- .onFocusChanged { focusState ->
- if (DEBUG) {
- Timber.v(
- "$index isFocused=${focusState.isFocused}",
- )
+ onEnter = {
+ if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) {
+ focusedIndex = startPosition
+ }
}
- if (focusState.isFocused) {
- // Focused, so set that up
- focusOn(index)
- positionCallback?.invoke(columns, index)
- } else if (focusedIndex == index) {
+ },
+ ) {
+ items(pager.size) { index ->
+ val mod =
+ if ((index == focusedIndex) or (focusedIndex < 0 && index == 0)) {
+ if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index")
+ Modifier
+ .focusRequester(firstFocus)
+ .focusRequester(gridFocusRequester)
+ .focusRequester(alphabetFocusRequester)
+ } else {
+ Modifier
+ }
+ val item = pager[index]
+ cardContent(
+ item,
+ {
+ if (item != null) {
+ focusedIndex = index
+ onClickItem.invoke(index, item)
+ }
+ },
+ { if (item != null) onLongClickItem.invoke(index, item) },
+ mod
+ .ifElse(index == 0, Modifier.focusRequester(zeroFocus))
+ .onFocusChanged { focusState ->
+ if (DEBUG) {
+ Timber.v(
+ "$index isFocused=${focusState.isFocused}",
+ )
+ }
+ if (focusState.isFocused) {
+ // Focused, so set that up
+ focusOn(index)
+ positionCallback?.invoke(columns, index)
+ } else if (focusedIndex == index) {
// savedFocusedIndex = index
// // Was focused on this, so mark unfocused
// focusedIndex = -1
- }
- },
- )
+ }
+ },
+ )
+ }
}
- }
- if (pager.isEmpty()) {
+ if (pager.isEmpty()) {
// focusedIndex = -1
- Box(modifier = Modifier.fillMaxSize()) {
- Text(
- text = stringResource(R.string.no_results),
- color = MaterialTheme.colorScheme.onBackground,
- modifier = Modifier.align(Alignment.Center),
- )
+ Box(modifier = Modifier.fillMaxSize()) {
+ Text(
+ text = stringResource(R.string.no_results),
+ color = MaterialTheme.colorScheme.onBackground,
+ modifier = Modifier.align(Alignment.Center),
+ )
+ }
}
- }
- if (showFooter) {
- // Footer
- Box(
- modifier =
- Modifier
- .align(Alignment.BottomCenter)
- .background(AppColors.TransparentBlack50),
- ) {
- val index = (focusedIndex + 1).takeIf { it > 0 } ?: "?"
+ if (showFooter) {
+ // Footer
+ Box(
+ modifier =
+ Modifier
+ .align(Alignment.BottomCenter)
+ .background(AppColors.TransparentBlack50),
+ ) {
+ val index = (focusedIndex + 1).takeIf { it > 0 } ?: "?"
// if (focusedIndex >= 0) {
// focusedIndex + 1
// } else {
// max(savedFocusedIndex, focusedIndexOnExit) + 1
// }
- Text(
- modifier = Modifier.padding(4.dp),
- color = MaterialTheme.colorScheme.onBackground,
- text = "$index / ${pager.size}",
- )
+ Text(
+ modifier = Modifier.padding(4.dp),
+ color = MaterialTheme.colorScheme.onBackground,
+ text = "$index / ${pager.size}",
+ )
+ }
}
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt
index fd4d46d3..4ddc41c1 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt
@@ -65,7 +65,7 @@ fun CollectionFolderPhotoAlbum(
} else {
item.destination(index)
}
- viewModel.navigationManager.navigateTo(destination)
+ viewModel.navigateTo(destination)
},
itemId = itemId.toServerString(),
initialFilter = filter,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt
index 0abcdc07..cfae65ba 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt
@@ -1,8 +1,10 @@
package com.github.damontecres.wholphin.ui.detail
import android.content.Context
+import android.hardware.display.DisplayManager
import android.os.Build
import android.util.Log
+import android.view.Display
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.scrollBy
@@ -32,11 +34,11 @@ import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.BuildConfig
+import com.github.damontecres.wholphin.MainActivity
import com.github.damontecres.wholphin.data.ItemPlaybackDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.preferences.UserPreferences
-import com.github.damontecres.wholphin.services.RefreshRateService
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.ExceptionHandler
@@ -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>(listOf())
val logcat = MutableLiveData>(listOf())
+ val supportedModes by lazy {
+ val displayManager =
+ MainActivity.instance.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
+ val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
+ display.supportedModes.orEmpty()
+ }
+
init {
viewModelScope.launchIO {
serverRepository.currentUser.value?.rowId?.let {
@@ -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(),
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt
index 7df86615..0c7d08b9 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt
@@ -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 -> {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt
index cc80d404..a50e848d 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt
@@ -73,7 +73,8 @@ class DiscoverMovieViewModel
val canCancelRequest = MutableStateFlow(false)
val userConfig = seerrServerRepository.current.map { it?.config }
- val request4kEnabled = seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false }
+ val request4kEnabled =
+ seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false }
init {
init()
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt
index 64748bd0..3c036534 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt
@@ -105,13 +105,13 @@ fun DiscoverSeriesDetails(
when (val state = loading) {
is LoadingState.Error -> {
- ErrorMessage(state)
+ ErrorMessage(state, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
-> {
- LoadingPage()
+ LoadingPage(modifier)
}
LoadingState.Success -> {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt
index bac9f51a..63d5ee5d 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt
@@ -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 -> {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt
index 23ed08c3..2a40a399 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt
@@ -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 -> {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt
new file mode 100644
index 00000000..6717527e
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt
@@ -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(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),
+ )
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForViewModel.kt
new file mode 100644
index 00000000..baa98ef1
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForViewModel.kt
@@ -0,0 +1,71 @@
+package com.github.damontecres.wholphin.ui.detail.search
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.github.damontecres.wholphin.data.ServerRepository
+import com.github.damontecres.wholphin.services.NavigationManager
+import com.github.damontecres.wholphin.ui.SlimItemFields
+import com.github.damontecres.wholphin.ui.components.VoiceInputManager
+import com.github.damontecres.wholphin.ui.launchIO
+import com.github.damontecres.wholphin.ui.main.SearchResult
+import com.github.damontecres.wholphin.util.ApiRequestPager
+import com.github.damontecres.wholphin.util.GetItemsRequestHandler
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.update
+import org.jellyfin.sdk.api.client.ApiClient
+import org.jellyfin.sdk.model.api.BaseItemKind
+import org.jellyfin.sdk.model.api.request.GetItemsRequest
+import timber.log.Timber
+import javax.inject.Inject
+
+@HiltViewModel
+class SearchForViewModel
+ @Inject
+ constructor(
+ private val api: ApiClient,
+ private val serverRepository: ServerRepository,
+ val navigationManager: NavigationManager,
+ val voiceInputManager: VoiceInputManager,
+ ) : ViewModel() {
+ val state = MutableStateFlow(SearchForState())
+
+ init {
+ state.value = SearchForState()
+ }
+
+ fun search(
+ searchType: BaseItemKind,
+ query: String,
+ ) {
+ viewModelScope.launchIO {
+ if (state.value.query != query) {
+ if (query.isBlank()) {
+ state.update { SearchForState(query, SearchResult.NoQuery) }
+ return@launchIO
+ }
+ state.update { SearchForState(query, SearchResult.Searching) }
+ try {
+ val request =
+ GetItemsRequest(
+ userId = serverRepository.currentUser.value?.id,
+ searchTerm = query,
+ includeItemTypes = listOf(searchType),
+ recursive = true,
+ fields = SlimItemFields,
+ )
+ val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init()
+ state.update { SearchForState(query, SearchResult.Success(pager)) }
+ } catch (ex: Exception) {
+ Timber.e(ex)
+ state.update { SearchForState(query, SearchResult.Error(ex)) }
+ }
+ }
+ }
+ }
+ }
+
+data class SearchForState(
+ val query: String = "",
+ val results: SearchResult = SearchResult.NoQuery,
+)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt
index ff0ddade..aab6e056 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt
@@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.detail.series
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusState
@@ -31,17 +32,17 @@ fun FocusedEpisodeHeader(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
- EpisodeName(dto, modifier = Modifier)
+ EpisodeName(dto, modifier = Modifier.padding(start = 8.dp))
ep?.ui?.quickDetails?.let {
- QuickDetails(it, ep.timeRemainingOrRuntime)
+ QuickDetails(it, ep.timeRemainingOrRuntime, Modifier.padding(start = 8.dp))
}
if (dto != null) {
VideoStreamDetails(
chosenStreams = chosenStreams,
numberOfVersions = dto.mediaSourceCount ?: 0,
- modifier = Modifier,
+ modifier = Modifier.padding(start = 8.dp),
)
}
OverviewText(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt
index 55335057..579a3866 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt
@@ -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(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt
index a8a9d83f..8fba8ed6 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt
@@ -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 -> {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt
index 86e2b398..a0d120e1 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt
@@ -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(),
)
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt
index 691b00cb..97b974ff 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt
@@ -1,6 +1,8 @@
package com.github.damontecres.wholphin.ui.discover
import android.content.Context
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -11,6 +13,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -23,6 +26,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@@ -47,6 +51,7 @@ import com.github.damontecres.wholphin.ui.listToDotString
import com.github.damontecres.wholphin.ui.main.HomePageHeader
import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.ui.tryRequestFocus
+import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
import com.github.damontecres.wholphin.util.DataLoadingState
import com.google.common.cache.CacheBuilder
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -186,6 +191,7 @@ data class DiscoverState(
val upcomingTv: DiscoverRowData = DiscoverRowData.EMPTY,
)
+@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SeerrDiscoverPage(
preferences: UserPreferences,
@@ -249,28 +255,41 @@ fun SeerrDiscoverPage(
.padding(top = 24.dp, bottom = 16.dp, start = 32.dp)
.fillMaxHeight(.25f),
)
- LazyColumn(
- verticalArrangement = Arrangement.spacedBy(8.dp),
- contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 40.dp),
- modifier =
- Modifier
- .focusRestorer()
- .fillMaxSize(),
+ val density = LocalDensity.current
+ val spaceAbovePx =
+ with(density) {
+ // The size of the row titles & spacing
+ 50.dp.toPx()
+ }
+ val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current
+ CompositionLocalProvider(
+ LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx),
) {
- itemsIndexed(rows) { rowIndex, row ->
- DiscoverRow(
- row = row,
- onClickItem = { index, item ->
- position = RowColumn(rowIndex, index)
- viewModel.navigationManager.navigateTo(item.destination)
- },
- onLongClickItem = { index, item -> },
- onCardFocus = { index -> position = RowColumn(rowIndex, index) },
- focusRequester = focusRequesters[rowIndex],
- modifier =
- Modifier
- .fillMaxWidth(),
- )
+ LazyColumn(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 40.dp),
+ modifier =
+ Modifier
+ .focusRestorer()
+ .fillMaxSize(),
+ ) {
+ itemsIndexed(rows) { rowIndex, row ->
+ CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) {
+ DiscoverRow(
+ row = row,
+ onClickItem = { index, item ->
+ position = RowColumn(rowIndex, index)
+ viewModel.navigationManager.navigateTo(item.destination)
+ },
+ onLongClickItem = { index, item -> },
+ onCardFocus = { index -> position = RowColumn(rowIndex, index) },
+ focusRequester = focusRequesters[rowIndex],
+ modifier =
+ Modifier
+ .fillMaxWidth(),
+ )
+ }
+ }
}
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt
index 3a838815..85863650 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt
@@ -64,7 +64,7 @@ class SeerrRequestsViewModel
viewModelScope.launchIO {
backdropService.clearBackdrop()
}
- seerrServerRepository.current
+ seerrServerRepository.connection
.onEach { user ->
state.update { it.copy(requests = DataLoadingState.Loading) }
if (user != null) {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt
index cfb13ff7..cf8738c5 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt
@@ -1,8 +1,8 @@
package com.github.damontecres.wholphin.ui.main
-import androidx.compose.foundation.background
+import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.focusGroup
-import androidx.compose.foundation.focusable
+import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -16,10 +16,13 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@@ -31,9 +34,9 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
@@ -44,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(null) }
var showPlaylistDialog by remember { mutableStateOf(null) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
+ var position by rememberPosition()
HomePageContent(
homeRows = homeRows,
- onClickItem = { position, item ->
+ position = position,
+ onFocusPosition = { position = it },
+ onClickItem = { clickedPosition, item ->
+ position = clickedPosition
viewModel.navigationManager.navigateTo(item.destination())
},
- onLongClickItem = { position, item ->
+ onLongClickItem = { clickedPosition, item ->
+ position = clickedPosition
val dialogItems =
buildMoreDialogItemsForHome(
context = context,
@@ -183,43 +194,48 @@ fun HomePage(
}
}
+@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HomePageContent(
homeRows: List,
+ position: RowColumn,
+ onFocusPosition: (RowColumn) -> Unit,
onClickItem: (RowColumn, BaseItem) -> Unit,
onLongClickItem: (RowColumn, BaseItem) -> Unit,
onClickPlay: (RowColumn, BaseItem) -> Unit,
showClock: Boolean,
onUpdateBackdrop: (BaseItem) -> Unit,
modifier: Modifier = Modifier,
- onFocusPosition: ((RowColumn) -> Unit)? = null,
loadingState: LoadingState? = null,
+ listState: LazyListState = rememberLazyListState(),
+ takeFocus: Boolean = true,
+ showEmptyRows: Boolean = false,
) {
- var position by rememberPosition()
val focusedItem =
position.let {
(homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column)
}
- val listState = rememberLazyListState()
val rowFocusRequesters = remember(homeRows) { List(homeRows.size) { FocusRequester() } }
var firstFocused by remember { mutableStateOf(false) }
- LaunchedEffect(homeRows) {
- if (!firstFocused && homeRows.isNotEmpty()) {
- if (position.row >= 0) {
- val index = position.row.coerceIn(0, rowFocusRequesters.lastIndex)
- rowFocusRequesters.getOrNull(index)?.tryRequestFocus()
- firstFocused = true
- } else {
- // Waiting for the first home row to load, then focus on it
- homeRows
- .indexOfFirstOrNull { it is HomeRowLoadingState.Success && it.items.isNotEmpty() }
- ?.let {
- rowFocusRequesters[it].tryRequestFocus()
- firstFocused = true
- delay(50)
- listState.scrollToItem(it)
- }
+ if (takeFocus) {
+ LaunchedEffect(homeRows) {
+ if (!firstFocused && homeRows.isNotEmpty()) {
+ if (position.row >= 0) {
+ val index = position.row.coerceIn(0, rowFocusRequesters.lastIndex)
+ rowFocusRequesters.getOrNull(index)?.tryRequestFocus()
+ firstFocused = true
+ } else {
+ // Waiting for the first home row to load, then focus on it
+ homeRows
+ .indexOfFirstOrNull { it is HomeRowLoadingState.Success && it.items.isNotEmpty() }
+ ?.let {
+ rowFocusRequesters[it].tryRequestFocus()
+ firstFocused = true
+ delay(50)
+ listState.scrollToItem(it)
+ }
+ }
}
}
}
@@ -240,134 +256,110 @@ fun HomePageContent(
.padding(top = 48.dp, bottom = 32.dp, start = 8.dp)
.fillMaxHeight(.33f),
)
- LazyColumn(
- state = listState,
- verticalArrangement = Arrangement.spacedBy(8.dp),
- contentPadding =
- PaddingValues(
- bottom = Cards.height2x3,
- ),
- modifier =
- Modifier
- .focusRestorer(),
+ val density = LocalDensity.current
+ val spaceAbovePx =
+ with(density) {
+ // The size of the row titles & spacing
+ 50.dp.toPx()
+ }
+ val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current
+ CompositionLocalProvider(
+ LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx),
) {
- itemsIndexed(homeRows) { rowIndex, row ->
- when (val r = row) {
- is HomeRowLoadingState.Loading,
- is HomeRowLoadingState.Pending,
- -> {
- Column(
- verticalArrangement = Arrangement.spacedBy(8.dp),
- modifier = Modifier.animateItem(),
- ) {
- Text(
- text = r.title,
- style = MaterialTheme.typography.titleLarge,
- color = MaterialTheme.colorScheme.onBackground,
- )
- Text(
- text = stringResource(R.string.loading),
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.onBackground,
- )
- }
- }
+ LazyColumn(
+ state = listState,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ contentPadding =
+ PaddingValues(
+ bottom = Cards.height2x3,
+ ),
+ modifier =
+ Modifier
+ .focusRestorer(),
+ ) {
+ itemsIndexed(homeRows) { rowIndex, row ->
+ CompositionLocalProvider(
+ LocalBringIntoViewSpec provides defaultBringIntoViewSpec,
+ ) {
+ when (val r = row) {
+ is HomeRowLoadingState.Loading,
+ is HomeRowLoadingState.Pending,
+ -> {
+ FocusableItemRow(
+ title = r.title,
+ subtitle = stringResource(R.string.loading),
+ modifier = Modifier.animateItem(),
+ )
+ }
- is HomeRowLoadingState.Error -> {
- var focused by remember { mutableStateOf(false) }
- Column(
- verticalArrangement = Arrangement.spacedBy(8.dp),
- modifier =
- Modifier
- .onFocusChanged {
- focused = it.isFocused
- }.focusable()
- .background(
- if (focused) {
- // Just so the user can tell it has focus
- MaterialTheme.colorScheme.border.copy(alpha = .25f)
- } else {
- Color.Unspecified
+ is HomeRowLoadingState.Error -> {
+ FocusableItemRow(
+ title = r.title,
+ subtitle = r.localizedMessage,
+ isError = true,
+ modifier = Modifier.animateItem(),
+ )
+ }
+
+ is HomeRowLoadingState.Success -> {
+ if (row.items.isNotEmpty()) {
+ val viewOptions = row.viewOptions
+ ItemRow(
+ title = row.title,
+ items = row.items,
+ onClickItem = { index, item ->
+ onClickItem.invoke(RowColumn(rowIndex, index), item)
+ },
+ onLongClickItem = { index, item ->
+ onLongClickItem.invoke(
+ RowColumn(rowIndex, index),
+ item,
+ )
},
- ).animateItem(),
- ) {
- Text(
- text = r.title,
- style = MaterialTheme.typography.titleLarge,
- color = MaterialTheme.colorScheme.onBackground,
- )
- Text(
- text = r.localizedMessage,
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.error,
- )
- }
- }
-
- is HomeRowLoadingState.Success -> {
- if (row.items.isNotEmpty()) {
- ItemRow(
- title = row.title,
- items = row.items,
- onClickItem = { index, item ->
- onClickItem.invoke(RowColumn(rowIndex, index), item)
- },
- onLongClickItem = { index, item ->
- onLongClickItem.invoke(RowColumn(rowIndex, index), item)
- },
- modifier =
- Modifier
- .fillMaxWidth()
- .focusGroup()
- .focusRequester(rowFocusRequesters[rowIndex])
- .animateItem(),
- cardContent = { index, item, cardModifier, onClick, onLongClick ->
- BannerCard(
- name = item?.data?.seriesName ?: item?.name,
- item = item,
- aspectRatio = AspectRatios.TALL,
- cornerText = item?.ui?.episodeUnplayedCornerText,
- played = item?.data?.userData?.played ?: false,
- favorite = item?.favorite ?: false,
- playPercent =
- item?.data?.userData?.playedPercentage
- ?: 0.0,
- onClick = onClick,
- onLongClick = onLongClick,
modifier =
- cardModifier
- .onFocusChanged {
- if (it.isFocused) {
- position = RowColumn(rowIndex, index)
-// item?.let(onUpdateBackdrop)
- }
- if (it.isFocused && onFocusPosition != null) {
- val nonEmptyRowBefore =
- homeRows
- .subList(0, rowIndex)
- .count {
- it is HomeRowLoadingState.Success && it.items.isEmpty()
- }
- onFocusPosition.invoke(
- RowColumn(
- rowIndex - nonEmptyRowBefore,
- index,
- ),
- )
- }
- }.onKeyEvent {
- if (isPlayKeyUp(it) && item?.type?.playable == true) {
- Timber.v("Clicked play on ${item.id}")
- onClickPlay.invoke(position, item)
- return@onKeyEvent true
- }
- return@onKeyEvent false
- },
- interactionSource = null,
- cardHeight = Cards.height2x3,
+ Modifier
+ .fillMaxWidth()
+ .focusGroup()
+ .focusRequester(rowFocusRequesters[rowIndex])
+ .animateItem(),
+ horizontalPadding = viewOptions.spacing.dp,
+ cardContent = { index, item, cardModifier, onClick, onLongClick ->
+ HomePageCardContent(
+ index = index,
+ item = item,
+ onClick = onClick,
+ onLongClick = onLongClick,
+ viewOptions = viewOptions,
+ modifier =
+ cardModifier
+ .onFocusChanged {
+ if (it.isFocused) {
+ onFocusPosition?.invoke(
+ RowColumn(rowIndex, index),
+ )
+ }
+ }.onKeyEvent {
+ if (isPlayKeyUp(it) && item?.type?.playable == true) {
+ Timber.v("Clicked play on ${item.id}")
+ onClickPlay.invoke(
+ position,
+ item,
+ )
+ return@onKeyEvent true
+ }
+ return@onKeyEvent false
+ },
+ )
+ },
)
- },
- )
+ } else if (showEmptyRows) {
+ FocusableItemRow(
+ title = r.title,
+ subtitle = stringResource(R.string.no_results),
+ modifier = Modifier.animateItem(),
+ )
+ }
+ }
}
}
}
@@ -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,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt
index 0e571544..a8ce7fd9 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt
@@ -1,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.Pending)
- val refreshState = MutableLiveData(LoadingState.Pending)
- val watchingRows = MutableLiveData>(listOf())
- val latestRows = MutableLiveData>(listOf())
-
- private lateinit var preferences: UserPreferences
+ private val _state = MutableStateFlow(HomeState.EMPTY)
+ val state: StateFlow = _state
init {
datePlayedService.invalidateAll()
- init()
+// init()
}
fun init() {
- viewModelScope.launch(
- Dispatchers.IO +
- LoadingExceptionHandler(
- loadingState,
- "Error loading home page",
- ),
- ) {
+ viewModelScope.launchIO {
Timber.d("init HomeViewModel")
- val reload = loadingState.value != LoadingState.Success
- if (reload) {
- loadingState.setValueOnMain(LoadingState.Loading)
- }
- refreshState.setValueOnMain(LoadingState.Loading)
- this@HomeViewModel.preferences = userPreferencesService.getCurrent()
- val prefs = preferences.appPreferences.homePagePreferences
- val limit = prefs.maxItemsPerRow
- if (reload) {
- backdropService.clearBackdrop()
- }
try {
+ val preferences = userPreferencesService.getCurrent()
+ val prefs = preferences.appPreferences.homePagePreferences
+
serverRepository.currentUserDto.value?.let { userDto ->
- val includedIds =
- navDrawerItemRepository
- .getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems())
- .filter { it is ServerNavDrawerItem }
- .map { (it as ServerNavDrawerItem).itemId }
- val resume = latestNextUpService.getResume(userDto.id, limit, true)
- val nextUp =
- latestNextUpService.getNextUp(
- userDto.id,
- limit,
- prefs.enableRewatchingNextUp,
- false,
- 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,
- ),
- )
- }
- if (nextUp.isNotEmpty()) {
- add(
- HomeRowLoadingState.Success(
- title = context.getString(R.string.next_up),
- items = nextUp,
- ),
- )
+ val libraries =
+ navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess)
+ val settings =
+ homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY }
+ val state = state.value
+
+ // Refreshing if a load has already occurred and the rows haven't significantly changed
+ val refresh =
+ state.loadingState == LoadingState.Success && state.settings == settings
+ Timber.v("refresh=$refresh, state.loadingState=${state.loadingState}")
+ _state.update { it.copy(settings = settings) }
+
+ val semaphore = Semaphore(4)
+
+ val watchingRowIndexes =
+ settings.rows
+ .mapIndexedNotNull { index, row ->
+ if (isWatchingRow(row.config)) index else null
+ }
+ val deferred =
+ settings.rows
+ // Load the watching rows first
+ .sortedByDescending { isWatchingRow(it.config) }
+ .map { row ->
+ viewModelScope.async(Dispatchers.IO) {
+ semaphore.withPermit {
+ Timber.v("Fetching row: %s", row)
+ try {
+ homeSettingsService.fetchDataForRow(
+ row = row.config,
+ scope = viewModelScope,
+ prefs = prefs,
+ userDto = userDto,
+ libraries = libraries,
+ limit = prefs.maxItemsPerRow,
+ isRefresh = refresh,
+ )
+ } catch (ex: Exception) {
+ Timber.e(ex, "Error on row %s", row)
+ HomeRowLoadingState.Error(row.title, exception = ex)
+ }
+ }
}
}
- }
- val latest = latestNextUpService.getLatest(userDto, limit, includedIds)
- val pendingLatest = latest.map { HomeRowLoadingState.Loading(it.title) }
-
- withContext(Dispatchers.Main) {
- this@HomeViewModel.watchingRows.value = watching
- if (reload) {
- this@HomeViewModel.latestRows.value = pendingLatest
+ if (refresh && state.homeRows.isNotEmpty() && watchingRowIndexes.isNotEmpty()) {
+ // Replace watching rows first
+ Timber.v("Refreshing rows: %s", watchingRowIndexes)
+ val rows =
+ deferred
+ .filterIndexed { index, _ -> index in watchingRowIndexes }
+ .awaitAll()
+ _state.update {
+ val newRows =
+ it.homeRows.toMutableList().apply {
+ rows.forEachIndexed { index, row ->
+ set(watchingRowIndexes[index], row)
+ }
+ }
+ it.copy(
+ loadingState = LoadingState.Success,
+ homeRows = newRows,
+ )
}
- loadingState.value = LoadingState.Success
}
- refreshState.setValueOnMain(LoadingState.Success)
- val loadedLatest = latestNextUpService.loadLatest(latest)
- this@HomeViewModel.latestRows.setValueOnMain(loadedLatest)
+ val rows =
+ deferred
+ .awaitAll()
+ .filter {
+ // Include only errors & non-empty successes
+ it is HomeRowLoadingState.Error ||
+ (it is HomeRowLoadingState.Success && it.items.isNotEmpty())
+ }
+ Timber.v("Got all rows")
+ _state.update {
+ it.copy(
+ loadingState = LoadingState.Success,
+ refreshState = LoadingState.Success,
+ homeRows = rows,
+ )
+ }
}
} catch (ex: Exception) {
- Timber.e(ex)
- if (!reload) {
- loadingState.setValueOnMain(LoadingState.Error(ex))
- } else {
+ Timber.e(ex, "Exception during home page loading")
+ if (state.value.loadingState == LoadingState.Success) {
showToast(context, "Error refreshing home: ${ex.localizedMessage}")
+ } else {
+ _state.update {
+ it.copy(loadingState = LoadingState.Error(ex))
+ }
}
}
}
@@ -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,
+ val settings: HomePageResolvedSettings,
+) {
+ companion object {
+ val EMPTY =
+ HomeState(
+ LoadingState.Pending,
+ LoadingState.Pending,
+ listOf(),
+ HomePageResolvedSettings.EMPTY,
+ )
+ }
+}
-data class LatestData(
- val title: String,
- val request: GetLatestMediaRequest,
-)
+/**
+ * Whether a row is a "is watching" type
+ */
+private fun isWatchingRow(row: HomeRowConfig) =
+ row is HomeRowConfig.ContinueWatching ||
+ row is HomeRowConfig.NextUp ||
+ row is HomeRowConfig.ContinueWatchingCombined
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt
new file mode 100644
index 00000000..4236c03a
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt
@@ -0,0 +1,132 @@
+package com.github.damontecres.wholphin.ui.main.settings
+
+import androidx.annotation.StringRes
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.focusRestorer
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.ListItem
+import androidx.tv.material3.Text
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.services.SuggestionsWorker
+import com.github.damontecres.wholphin.ui.ifElse
+import com.github.damontecres.wholphin.ui.tryRequestFocus
+import org.jellyfin.sdk.model.api.CollectionType
+
+@Composable
+fun HomeLibraryRowTypeList(
+ library: Library,
+ onClick: (LibraryRowType) -> Unit,
+ modifier: Modifier,
+ firstFocus: FocusRequester = remember { FocusRequester() },
+) {
+ val items = remember(library) { getSupportedRowTypes(library) }
+ LaunchedEffect(Unit) { firstFocus.tryRequestFocus() }
+ Column(modifier = modifier) {
+ TitleText(stringResource(R.string.add_row_for, library.name))
+ LazyColumn(
+ contentPadding = PaddingValues(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier =
+ modifier
+ .fillMaxHeight()
+ .focusRestorer(firstFocus),
+ ) {
+ itemsIndexed(items) { index, rowType ->
+ ListItem(
+ selected = false,
+ headlineContent = {
+ Text(
+ text = stringResource(rowType.stringId),
+ )
+ },
+ onClick = { onClick.invoke(rowType) },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .ifElse(index == 0, Modifier.focusRequester(firstFocus)),
+ )
+ }
+ }
+ }
+}
+
+fun getSupportedRowTypes(library: Library): List {
+ val supportsSuggestions = SuggestionsWorker.getTypeForCollection(library.collectionType) != null
+ return when {
+ library.isRecordingFolder -> {
+ listOf(
+ LibraryRowType.RECENTLY_RECORDED,
+ LibraryRowType.GENRES,
+ )
+ }
+
+ library.collectionType == CollectionType.LIVETV -> {
+ listOf(
+ LibraryRowType.TV_CHANNELS,
+ LibraryRowType.TV_PROGRAMS,
+ )
+ }
+
+ supportsSuggestions -> {
+ listOf(
+ LibraryRowType.RECENTLY_ADDED,
+ LibraryRowType.RECENTLY_RELEASED,
+ LibraryRowType.SUGGESTIONS,
+ LibraryRowType.GENRES,
+ )
+ }
+
+ library.collectionType == CollectionType.BOXSETS -> {
+ listOf(
+ LibraryRowType.RECENTLY_ADDED,
+ LibraryRowType.RECENTLY_RELEASED,
+ LibraryRowType.GENRES,
+ LibraryRowType.COLLECTION,
+ )
+ }
+
+ library.collectionType == CollectionType.PLAYLISTS -> {
+ listOf(
+ LibraryRowType.RECENTLY_ADDED,
+ LibraryRowType.RECENTLY_RELEASED,
+ LibraryRowType.GENRES,
+ LibraryRowType.PLAYLIST,
+ )
+ }
+
+ else -> {
+ listOf(
+ LibraryRowType.RECENTLY_ADDED,
+ LibraryRowType.RECENTLY_RELEASED,
+ LibraryRowType.GENRES,
+ )
+ }
+ }
+}
+
+enum class LibraryRowType(
+ @param:StringRes val stringId: Int,
+) {
+ RECENTLY_ADDED(R.string.recently_added),
+ RECENTLY_RELEASED(R.string.recently_released),
+ SUGGESTIONS(R.string.suggestions),
+ GENRES(R.string.genres),
+ TV_CHANNELS(R.string.channels),
+ TV_PROGRAMS(R.string.live_tv),
+ RECENTLY_RECORDED(R.string.recently_recorded),
+ COLLECTION(R.string.collection),
+ PLAYLIST(R.string.playlist),
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt
new file mode 100644
index 00000000..eaf44d81
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt
@@ -0,0 +1,251 @@
+package com.github.damontecres.wholphin.ui.main.settings
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.focusRestorer
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
+import com.github.damontecres.wholphin.preferences.PrefContentScale
+import com.github.damontecres.wholphin.ui.AspectRatio
+import com.github.damontecres.wholphin.ui.components.ViewOptionImageType
+import com.github.damontecres.wholphin.ui.tryRequestFocus
+import org.jellyfin.sdk.model.api.CollectionType
+
+data class HomeRowPresets(
+ val continueWatching: HomeRowViewOptions,
+ val movieLibrary: HomeRowViewOptions,
+ val tvLibrary: HomeRowViewOptions,
+ val videoLibrary: HomeRowViewOptions,
+ val photoLibrary: HomeRowViewOptions,
+ val playlist: HomeRowViewOptions,
+ val liveTv: HomeRowViewOptions,
+ val genreSize: Int,
+) {
+ fun getByCollectionType(collectionType: CollectionType): HomeRowViewOptions =
+ when (collectionType) {
+ CollectionType.MOVIES -> movieLibrary
+
+ CollectionType.TVSHOWS -> tvLibrary
+
+ CollectionType.MUSICVIDEOS -> videoLibrary
+
+ CollectionType.TRAILERS -> videoLibrary
+
+ CollectionType.HOMEVIDEOS -> videoLibrary
+
+ CollectionType.BOXSETS -> movieLibrary
+
+ CollectionType.PHOTOS -> photoLibrary
+
+ CollectionType.LIVETV -> liveTv
+
+ CollectionType.UNKNOWN,
+ CollectionType.MUSIC,
+ CollectionType.BOOKS,
+ CollectionType.PLAYLISTS,
+ CollectionType.FOLDERS,
+ -> HomeRowViewOptions()
+ }
+
+ companion object {
+ val WholphinDefault by lazy {
+ HomeRowPresets(
+ continueWatching = HomeRowViewOptions(),
+ movieLibrary = HomeRowViewOptions(),
+ tvLibrary = HomeRowViewOptions(),
+ videoLibrary =
+ HomeRowViewOptions(
+ aspectRatio = AspectRatio.WIDE,
+ ),
+ photoLibrary =
+ HomeRowViewOptions(
+ aspectRatio = AspectRatio.WIDE,
+ contentScale = PrefContentScale.CROP,
+ ),
+ playlist =
+ HomeRowViewOptions(
+ aspectRatio = AspectRatio.SQUARE,
+ contentScale = PrefContentScale.FIT,
+ ),
+ liveTv = HomeRowViewOptions.liveTvDefault,
+ genreSize = HomeRowViewOptions.genreDefault.heightDp,
+ )
+ }
+
+ val WholphinCompact by lazy {
+ val height = 148
+ val epHeight = 100
+ HomeRowPresets(
+ continueWatching =
+ HomeRowViewOptions(
+ heightDp = height,
+ ),
+ movieLibrary =
+ HomeRowViewOptions(
+ heightDp = height,
+ ),
+ tvLibrary =
+ HomeRowViewOptions(
+ heightDp = height,
+ ),
+ videoLibrary =
+ HomeRowViewOptions(
+ heightDp = epHeight,
+ aspectRatio = AspectRatio.WIDE,
+ ),
+ photoLibrary =
+ HomeRowViewOptions(
+ heightDp = epHeight,
+ aspectRatio = AspectRatio.WIDE,
+ contentScale = PrefContentScale.CROP,
+ ),
+ playlist =
+ HomeRowViewOptions(
+ heightDp = epHeight,
+ aspectRatio = AspectRatio.SQUARE,
+ contentScale = PrefContentScale.FIT,
+ ),
+ liveTv = HomeRowViewOptions.liveTvDefault,
+ genreSize = epHeight,
+ )
+ }
+
+ val SeriesThumbs by lazy {
+ val height = 148
+ val epHeight = 100
+ HomeRowPresets(
+ continueWatching =
+ HomeRowViewOptions(
+ heightDp = epHeight,
+ imageType = ViewOptionImageType.THUMB,
+ aspectRatio = AspectRatio.WIDE,
+ useSeries = true,
+ episodeImageType = ViewOptionImageType.THUMB,
+ episodeAspectRatio = AspectRatio.WIDE,
+ ),
+ movieLibrary =
+ HomeRowViewOptions(
+ heightDp = height,
+ ),
+ tvLibrary =
+ HomeRowViewOptions(
+ heightDp = height,
+ ),
+ videoLibrary =
+ HomeRowViewOptions(
+ heightDp = epHeight,
+ aspectRatio = AspectRatio.WIDE,
+ ),
+ photoLibrary =
+ HomeRowViewOptions(
+ heightDp = epHeight,
+ aspectRatio = AspectRatio.WIDE,
+ contentScale = PrefContentScale.CROP,
+ ),
+ playlist =
+ HomeRowViewOptions(
+ heightDp = epHeight,
+ aspectRatio = AspectRatio.SQUARE,
+ contentScale = PrefContentScale.FIT,
+ ),
+ liveTv = HomeRowViewOptions.liveTvDefault,
+ genreSize = epHeight,
+ )
+ }
+
+ val EpisodeThumbnails by lazy {
+ val height = 148
+ val epHeight = 100
+ HomeRowPresets(
+ continueWatching =
+ HomeRowViewOptions(
+ heightDp = epHeight,
+ imageType = ViewOptionImageType.THUMB,
+ aspectRatio = AspectRatio.WIDE,
+ showTitles = true,
+ useSeries = false,
+ episodeImageType = ViewOptionImageType.PRIMARY,
+ episodeAspectRatio = AspectRatio.WIDE,
+ ),
+ movieLibrary =
+ HomeRowViewOptions(
+ heightDp = height,
+ ),
+ tvLibrary =
+ HomeRowViewOptions(
+ heightDp = height,
+ ),
+ videoLibrary =
+ HomeRowViewOptions(
+ heightDp = epHeight,
+ aspectRatio = AspectRatio.WIDE,
+ ),
+ photoLibrary =
+ HomeRowViewOptions(
+ heightDp = epHeight,
+ aspectRatio = AspectRatio.WIDE,
+ contentScale = PrefContentScale.CROP,
+ ),
+ playlist =
+ HomeRowViewOptions(
+ heightDp = epHeight,
+ aspectRatio = AspectRatio.SQUARE,
+ contentScale = PrefContentScale.FIT,
+ ),
+ liveTv = HomeRowViewOptions.liveTvDefault,
+ genreSize = epHeight,
+ )
+ }
+ }
+}
+
+@Composable
+fun HomeRowPresetsContent(
+ onApply: (HomeRowPresets) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val presets =
+ listOf(
+ stringResource(R.string.display_preset_default) to HomeRowPresets.WholphinDefault,
+ stringResource(R.string.display_preset_compact) to HomeRowPresets.WholphinCompact,
+ stringResource(R.string.display_preset_series_thumb) to HomeRowPresets.SeriesThumbs,
+ stringResource(R.string.display_preset_episode_thumbnails) to HomeRowPresets.EpisodeThumbnails,
+ )
+
+ val focusRequesters = remember { List(presets.size) { FocusRequester() } }
+ LaunchedEffect(Unit) { focusRequesters[0].tryRequestFocus() }
+ Column(modifier = modifier) {
+ TitleText(stringResource(R.string.display_presets))
+ LazyColumn(
+ contentPadding = PaddingValues(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier =
+ modifier
+ .fillMaxHeight()
+ .focusRestorer(focusRequesters[0]),
+ ) {
+ itemsIndexed(presets) { index, (title, preset) ->
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = title,
+ onClick = {
+ onApply.invoke(preset)
+ },
+ modifier = Modifier.focusRequester(focusRequesters[index]),
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt
new file mode 100644
index 00000000..7ac43548
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt
@@ -0,0 +1,307 @@
+package com.github.damontecres.wholphin.ui.main.settings
+
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.res.stringResource
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
+import com.github.damontecres.wholphin.preferences.AppChoicePreference
+import com.github.damontecres.wholphin.preferences.AppClickablePreference
+import com.github.damontecres.wholphin.preferences.AppPreference
+import com.github.damontecres.wholphin.preferences.AppSliderPreference
+import com.github.damontecres.wholphin.preferences.AppSwitchPreference
+import com.github.damontecres.wholphin.preferences.PrefContentScale
+import com.github.damontecres.wholphin.ui.AspectRatio
+import com.github.damontecres.wholphin.ui.Cards
+import com.github.damontecres.wholphin.ui.components.ViewOptionImageType
+import com.github.damontecres.wholphin.ui.ifElse
+import com.github.damontecres.wholphin.ui.preferences.ComposablePreference
+import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup
+import com.github.damontecres.wholphin.ui.tryRequestFocus
+
+@Composable
+fun HomeRowSettings(
+ title: String,
+ preferenceOptions: List>,
+ viewOptions: HomeRowViewOptions,
+ onViewOptionsChange: (HomeRowViewOptions) -> Unit,
+ onApplyApplyAll: () -> Unit,
+ modifier: Modifier = Modifier,
+ defaultViewOptions: HomeRowViewOptions = HomeRowViewOptions(),
+) {
+ val firstFocus = remember { FocusRequester() }
+ LaunchedEffect(Unit) { firstFocus.tryRequestFocus() }
+ Column(modifier = modifier) {
+ TitleText(title)
+ LazyColumn {
+ preferenceOptions.forEachIndexed { groupIndex, prefGroup ->
+ if (preferenceOptions.size > 1) {
+ item {
+ TitleText(stringResource(prefGroup.title))
+ }
+ }
+ itemsIndexed(prefGroup.preferences) { index, pref ->
+ pref as AppPreference
+ val interactionSource = remember { MutableInteractionSource() }
+ val value = pref.getter.invoke(viewOptions)
+ ComposablePreference(
+ preference = pref,
+ value = value,
+ onNavigate = {},
+ onValueChange = { newValue ->
+ onViewOptionsChange.invoke(pref.setter(viewOptions, newValue))
+ },
+ interactionSource = interactionSource,
+ onClickPreference = { pref ->
+ when (pref) {
+ Options.ViewOptionsReset -> {
+ onViewOptionsChange.invoke(defaultViewOptions)
+ }
+
+ Options.ViewOptionsApplyAll -> {
+ onApplyApplyAll.invoke()
+ }
+
+ Options.ViewOptionsUseThumb -> {
+ onViewOptionsChange.invoke(
+ viewOptions.copy(
+ heightDp = Cards.HEIGHT_EPISODE,
+ spacing = 20,
+ imageType = ViewOptionImageType.THUMB,
+ aspectRatio = AspectRatio.WIDE,
+ contentScale = PrefContentScale.FIT,
+ episodeImageType = ViewOptionImageType.THUMB,
+ episodeAspectRatio = AspectRatio.WIDE,
+ episodeContentScale = PrefContentScale.FIT,
+ ),
+ )
+ }
+ }
+ },
+ modifier =
+ Modifier
+ .ifElse(
+ groupIndex == 0 && index == 0,
+ Modifier.focusRequester(firstFocus),
+ ),
+ )
+ }
+ }
+ }
+ }
+}
+
+internal object Options {
+ val ViewOptionsCardHeight =
+ AppSliderPreference(
+ title = R.string.height,
+ defaultValue = Cards.HEIGHT_2X3_DP.toLong(),
+ min = 64L,
+ max = Cards.HEIGHT_2X3_DP + 64L,
+ interval = 4,
+ getter = { it.heightDp.toLong() },
+ setter = { prefs, value -> prefs.copy(heightDp = value.toInt()) },
+ )
+ val ViewOptionsSpacing =
+ AppSliderPreference(
+ title = R.string.spacing,
+ defaultValue = 16,
+ min = 0,
+ max = 32,
+ interval = 2,
+ getter = { it.spacing.toLong() },
+ setter = { prefs, value -> prefs.copy(spacing = value.toInt()) },
+ )
+
+ val ViewOptionsContentScale =
+ AppChoicePreference(
+ title = R.string.global_content_scale,
+ defaultValue = PrefContentScale.FIT,
+ displayValues = R.array.content_scale,
+ getter = { it.contentScale },
+ setter = { viewOptions, value -> viewOptions.copy(contentScale = value) },
+ indexToValue = { PrefContentScale.forNumber(it) },
+ valueToIndex = { it.number },
+ )
+
+ val ViewOptionsAspectRatio =
+ AppChoicePreference(
+ title = R.string.aspect_ratio,
+ defaultValue = AspectRatio.TALL,
+ displayValues = R.array.aspect_ratios,
+ getter = { it.aspectRatio },
+ setter = { viewOptions, value -> viewOptions.copy(aspectRatio = value) },
+ indexToValue = { AspectRatio.entries[it] },
+ valueToIndex = { it.ordinal },
+ )
+
+ val ViewOptionsShowTitles =
+ AppSwitchPreference(
+ title = R.string.show_titles,
+ defaultValue = true,
+ getter = { it.showTitles },
+ setter = { vo, value -> vo.copy(showTitles = value) },
+ )
+
+ val ViewOptionsUseSeries =
+ AppSwitchPreference(
+ title = R.string.use_series,
+ defaultValue = true,
+ getter = { it.useSeries },
+ setter = { vo, value -> vo.copy(useSeries = value) },
+ )
+
+ val ViewOptionsImageType =
+ AppChoicePreference(
+ title = R.string.image_type,
+ defaultValue = ViewOptionImageType.PRIMARY,
+ displayValues = R.array.image_types,
+ getter = { it.imageType },
+ setter = { viewOptions, value ->
+ val aspectRatio =
+ when (value) {
+ ViewOptionImageType.PRIMARY -> AspectRatio.TALL
+ ViewOptionImageType.THUMB -> AspectRatio.WIDE
+ }
+ viewOptions.copy(imageType = value, aspectRatio = aspectRatio)
+ },
+ indexToValue = { ViewOptionImageType.entries[it] },
+ valueToIndex = { it.ordinal },
+ )
+
+ val ViewOptionsApplyAll =
+ AppClickablePreference(
+ title = R.string.apply_all_rows,
+ )
+
+ val ViewOptionsReset =
+ AppClickablePreference(
+ title = R.string.reset,
+ )
+
+ val ViewOptionsUseThumb =
+ AppClickablePreference(
+ title = R.string.use_thumb_images,
+ )
+
+ val ViewOptionsEpisodeContentScale =
+ AppChoicePreference(
+ title = R.string.global_content_scale,
+ defaultValue = PrefContentScale.FIT,
+ displayValues = R.array.content_scale,
+ getter = { it.episodeContentScale },
+ setter = { viewOptions, value -> viewOptions.copy(episodeContentScale = value) },
+ indexToValue = { PrefContentScale.forNumber(it) },
+ valueToIndex = { it.number },
+ )
+
+ val ViewOptionsEpisodeAspectRatio =
+ AppChoicePreference(
+ title = R.string.aspect_ratio,
+ defaultValue = AspectRatio.TALL,
+ displayValues = R.array.aspect_ratios,
+ getter = { it.episodeAspectRatio },
+ setter = { viewOptions, value -> viewOptions.copy(episodeAspectRatio = value) },
+ indexToValue = { AspectRatio.entries[it] },
+ valueToIndex = { it.ordinal },
+ )
+
+ val ViewOptionsEpisodeImageType =
+ AppChoicePreference(
+ title = R.string.image_type,
+ defaultValue = ViewOptionImageType.PRIMARY,
+ displayValues = R.array.image_types,
+ getter = { it.episodeImageType },
+ setter = { viewOptions, value ->
+ val aspectRatio =
+ when (value) {
+ ViewOptionImageType.PRIMARY -> AspectRatio.TALL
+ ViewOptionImageType.THUMB -> AspectRatio.WIDE
+ }
+ viewOptions.copy(episodeImageType = value, episodeAspectRatio = aspectRatio)
+ },
+ indexToValue = { ViewOptionImageType.entries[it] },
+ valueToIndex = { it.ordinal },
+ )
+
+ val OPTIONS =
+ listOf(
+ PreferenceGroup(
+ title = R.string.general,
+ preferences =
+ listOf(
+ ViewOptionsCardHeight,
+ ViewOptionsSpacing,
+ ViewOptionsShowTitles,
+ ViewOptionsImageType,
+ ViewOptionsAspectRatio,
+ ViewOptionsContentScale,
+ ViewOptionsUseSeries,
+ ),
+ ),
+ PreferenceGroup(
+ title = R.string.more,
+ preferences =
+ listOf(
+// ViewOptionsApplyAll,
+ ViewOptionsUseThumb,
+ ViewOptionsReset,
+ ),
+ ),
+ )
+
+ val OPTIONS_EPISODES =
+ listOf(
+ PreferenceGroup(
+ title = R.string.general,
+ preferences =
+ listOf(
+ ViewOptionsCardHeight,
+ ViewOptionsSpacing,
+ ViewOptionsShowTitles,
+ ViewOptionsImageType,
+ ViewOptionsAspectRatio,
+ ViewOptionsContentScale,
+ ),
+ ),
+ PreferenceGroup(
+ title = R.string.for_episodes,
+ preferences =
+ listOf(
+ ViewOptionsUseSeries,
+ ViewOptionsEpisodeImageType,
+ ViewOptionsEpisodeAspectRatio,
+ ViewOptionsEpisodeContentScale,
+ ),
+ ),
+ PreferenceGroup(
+ title = R.string.more,
+ preferences =
+ listOf(
+ ViewOptionsUseThumb,
+ ViewOptionsReset,
+ ),
+ ),
+ )
+
+ val GENRE_OPTIONS =
+ listOf(
+ PreferenceGroup(
+ title = R.string.general,
+ preferences =
+ listOf(
+ ViewOptionsCardHeight,
+ ViewOptionsSpacing,
+ ViewOptionsReset,
+ ),
+ ),
+ )
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt
new file mode 100644
index 00000000..97249aa4
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt
@@ -0,0 +1,110 @@
+package com.github.damontecres.wholphin.ui.main.settings
+
+import androidx.annotation.StringRes
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.focusRestorer
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.ui.ifElse
+
+@Composable
+fun HomeSettingsAddRow(
+ libraries: List,
+ showDiscover: Boolean,
+ onClick: (Library) -> Unit,
+ onClickMeta: (MetaRowType) -> Unit,
+ modifier: Modifier,
+ firstFocus: FocusRequester = remember { FocusRequester() },
+) {
+// LaunchedEffect(Unit) { firstFocus.tryRequestFocus() }
+ Column(modifier = modifier) {
+ TitleText(stringResource(R.string.add_row))
+ LazyColumn(
+ contentPadding = PaddingValues(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier =
+ modifier
+ .fillMaxHeight()
+ .focusRestorer(firstFocus),
+ ) {
+ itemsIndexed(
+ listOf(
+ MetaRowType.CONTINUE_WATCHING,
+ MetaRowType.NEXT_UP,
+ MetaRowType.COMBINED_CONTINUE_WATCHING,
+ ),
+ ) { index, type ->
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = stringResource(type.stringId),
+ onClick = { onClickMeta.invoke(type) },
+ modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(firstFocus)),
+ )
+ }
+ item {
+ TitleText(stringResource(R.string.library))
+ HorizontalDivider()
+ }
+ itemsIndexed(libraries) { index, library ->
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = library.name,
+ onClick = { onClick.invoke(library) },
+ modifier = Modifier, // .ifElse(index == 0, Modifier.focusRequester(firstFocus)),
+ )
+ }
+ item {
+ TitleText(stringResource(R.string.more))
+ HorizontalDivider()
+ }
+ itemsIndexed(
+ listOf(
+ MetaRowType.FAVORITES,
+ MetaRowType.COLLECTION,
+ MetaRowType.PLAYLIST,
+ ),
+ ) { index, type ->
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = stringResource(type.stringId),
+ onClick = { onClickMeta.invoke(type) },
+ modifier = Modifier,
+ )
+ }
+ if (showDiscover) {
+ item {
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = stringResource(MetaRowType.DISCOVER.stringId),
+ onClick = { onClickMeta.invoke(MetaRowType.DISCOVER) },
+ modifier = Modifier,
+ )
+ }
+ }
+ }
+ }
+}
+
+enum class MetaRowType(
+ @param:StringRes val stringId: Int,
+) {
+ CONTINUE_WATCHING(R.string.continue_watching),
+ NEXT_UP(R.string.next_up),
+ COMBINED_CONTINUE_WATCHING(R.string.combine_continue_next),
+ FAVORITES(R.string.favorites),
+ DISCOVER(R.string.discover),
+ COLLECTION(R.string.collection),
+ PLAYLIST(R.string.playlist),
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt
new file mode 100644
index 00000000..ad433c23
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt
@@ -0,0 +1,38 @@
+package com.github.damontecres.wholphin.ui.main.settings
+
+import androidx.navigation3.runtime.NavKey
+import kotlinx.serialization.Serializable
+
+/**
+ * Tracking the pages for selecting and configuring rows
+ */
+@Serializable
+sealed interface HomeSettingsDestination : NavKey {
+ @Serializable
+ data object RowList : HomeSettingsDestination
+
+ @Serializable
+ data object AddRow : HomeSettingsDestination
+
+ @Serializable
+ data class ChooseRowType(
+ val library: Library,
+ ) : HomeSettingsDestination
+
+ @Serializable
+ data class RowSettings(
+ val rowId: Int,
+ ) : HomeSettingsDestination
+
+ @Serializable
+ data object ChooseFavorite : HomeSettingsDestination
+
+ @Serializable
+ data object ChooseDiscover : HomeSettingsDestination
+
+ @Serializable
+ data object GlobalSettings : HomeSettingsDestination
+
+ @Serializable
+ data object Presets : HomeSettingsDestination
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt
new file mode 100644
index 00000000..3bac590a
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt
@@ -0,0 +1,64 @@
+package com.github.damontecres.wholphin.ui.main.settings
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.focusRestorer
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.ui.ifElse
+import com.github.damontecres.wholphin.ui.tryRequestFocus
+import org.jellyfin.sdk.model.api.BaseItemKind
+
+@Composable
+fun HomeSettingsFavoriteList(
+ onClick: (BaseItemKind) -> Unit,
+ modifier: Modifier = Modifier,
+ firstFocus: FocusRequester = remember { FocusRequester() },
+) {
+ LaunchedEffect(Unit) { firstFocus.tryRequestFocus() }
+ Column(modifier = modifier) {
+ TitleText(
+ stringResource(R.string.add_row_for, stringResource(R.string.favorites)),
+ )
+ LazyColumn(
+ contentPadding = PaddingValues(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier =
+ modifier
+ .fillMaxHeight()
+ .focusRestorer(firstFocus),
+ ) {
+ itemsIndexed(favoriteOptionsList) { index, type ->
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = stringResource(favoriteOptions[type]!!),
+ onClick = { onClick.invoke(type) },
+ modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(firstFocus)),
+ )
+ }
+ }
+ }
+}
+
+val favoriteOptions by lazy {
+ mapOf(
+ BaseItemKind.MOVIE to R.string.movies,
+ BaseItemKind.SERIES to R.string.tv_shows,
+ BaseItemKind.EPISODE to R.string.episodes,
+ BaseItemKind.VIDEO to R.string.videos,
+ BaseItemKind.PLAYLIST to R.string.playlists,
+ BaseItemKind.PERSON to R.string.people,
+ )
+}
+val favoriteOptionsList by lazy { favoriteOptions.keys.toList() }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt
new file mode 100644
index 00000000..e4b61881
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt
@@ -0,0 +1,184 @@
+package com.github.damontecres.wholphin.ui.main.settings
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.KeyboardArrowDown
+import androidx.compose.material.icons.filled.KeyboardArrowUp
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.focusRestorer
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.Icon
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.preferences.AppPreference
+import com.github.damontecres.wholphin.preferences.AppPreferences
+import com.github.damontecres.wholphin.ui.FontAwesome
+import com.github.damontecres.wholphin.ui.preferences.ComposablePreference
+import com.github.damontecres.wholphin.ui.tryRequestFocus
+
+@Composable
+fun HomeSettingsGlobal(
+ preferences: AppPreferences,
+ onPreferenceChange: (AppPreferences) -> Unit,
+ onClickResize: (Int) -> Unit,
+ onClickSave: () -> Unit,
+ onClickLoad: () -> Unit,
+ onClickLoadWeb: () -> Unit,
+ onClickReset: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val firstFocus: FocusRequester = remember { FocusRequester() }
+ LaunchedEffect(Unit) { firstFocus.tryRequestFocus() }
+ Column(modifier = modifier) {
+ Text(
+ text = stringResource(R.string.settings),
+ style = MaterialTheme.typography.titleLarge,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ HorizontalDivider()
+ LazyColumn(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier =
+ modifier
+ .fillMaxHeight()
+ .focusRestorer(firstFocus),
+ ) {
+ item {
+ ComposablePreference(
+ preference = AppPreference.HomePageItems,
+ value = AppPreference.HomePageItems.getter.invoke(preferences),
+ onValueChange = {
+ val newPrefs = AppPreference.HomePageItems.setter.invoke(preferences, it)
+ onPreferenceChange.invoke(newPrefs)
+ },
+ onNavigate = {},
+ modifier = Modifier.focusRequester(firstFocus),
+ )
+ }
+ item {
+ ComposablePreference(
+ preference = AppPreference.RewatchNextUp,
+ value = AppPreference.RewatchNextUp.getter.invoke(preferences),
+ onValueChange = {
+ val newPrefs = AppPreference.RewatchNextUp.setter.invoke(preferences, it)
+ onPreferenceChange.invoke(newPrefs)
+ },
+ onNavigate = {},
+ modifier = Modifier,
+ )
+ }
+ item {
+ ComposablePreference(
+ preference = AppPreference.MaxDaysNextUp,
+ value = AppPreference.MaxDaysNextUp.getter.invoke(preferences),
+ onValueChange = {
+ val newPrefs = AppPreference.MaxDaysNextUp.setter.invoke(preferences, it)
+ onPreferenceChange.invoke(newPrefs)
+ },
+ onNavigate = {},
+ modifier = Modifier,
+ )
+ }
+ item { HorizontalDivider() }
+ item {
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = stringResource(R.string.increase_all_cards_size),
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Default.KeyboardArrowUp,
+ contentDescription = null,
+ )
+ },
+ onClick = { onClickResize.invoke(1) },
+ modifier = Modifier,
+ )
+ }
+ item {
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = stringResource(R.string.decrease_all_cards_size),
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Default.KeyboardArrowDown,
+ contentDescription = null,
+ )
+ },
+ onClick = { onClickResize.invoke(-1) },
+ modifier = Modifier,
+ )
+ }
+ item { HorizontalDivider() }
+ item {
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = stringResource(R.string.save_to_server),
+ leadingContent = {
+ Text(
+ text = stringResource(R.string.fa_cloud_arrow_up),
+ fontFamily = FontAwesome,
+ )
+ },
+ onClick = onClickSave,
+ modifier = Modifier,
+ )
+ }
+ item {
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = stringResource(R.string.load_from_server),
+ leadingContent = {
+ Text(
+ text = stringResource(R.string.fa_cloud_arrow_down),
+ fontFamily = FontAwesome,
+ )
+ },
+ onClick = onClickLoad,
+ modifier = Modifier,
+ )
+ }
+ item {
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = stringResource(R.string.load_from_web_client),
+ leadingContent = {
+ Text(
+ text = stringResource(R.string.fa_download),
+ fontFamily = FontAwesome,
+ )
+ },
+ onClick = onClickLoadWeb,
+ modifier = Modifier,
+ )
+ }
+ item {
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = stringResource(R.string.reset),
+ leadingContent = {
+ Text(
+ text = stringResource(R.string.fa_arrows_rotate),
+ fontFamily = FontAwesome,
+ )
+ },
+ onClick = onClickReset,
+ modifier = Modifier,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsListItem.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsListItem.kt
new file mode 100644
index 00000000..4f265dbb
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsListItem.kt
@@ -0,0 +1,59 @@
+package com.github.damontecres.wholphin.ui.main.settings
+
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.NonRestartableComposable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.ListItem
+import androidx.tv.material3.ListItemBorder
+import androidx.tv.material3.ListItemColors
+import androidx.tv.material3.ListItemDefaults
+import androidx.tv.material3.ListItemGlow
+import androidx.tv.material3.ListItemScale
+import androidx.tv.material3.ListItemShape
+import com.github.damontecres.wholphin.ui.preferences.PreferenceTitle
+
+@Composable
+@NonRestartableComposable
+fun HomeSettingsListItem(
+ selected: Boolean,
+ onClick: () -> Unit,
+ headlineText: String,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ onLongClick: (() -> Unit)? = null,
+ overlineContent: (@Composable () -> Unit)? = null,
+ supportingContent: (@Composable () -> Unit)? = null,
+ leadingContent: (@Composable BoxScope.() -> Unit)? = null,
+ trailingContent: (@Composable () -> Unit)? = null,
+ tonalElevation: Dp = 3.dp,
+ shape: ListItemShape = ListItemDefaults.shape(),
+ colors: ListItemColors = ListItemDefaults.colors(),
+ scale: ListItemScale = ListItemDefaults.scale(),
+ border: ListItemBorder = ListItemDefaults.border(),
+ glow: ListItemGlow = ListItemDefaults.glow(),
+ interactionSource: MutableInteractionSource? = null,
+) = ListItem(
+ selected = selected,
+ onClick = onClick,
+ headlineContent = {
+ PreferenceTitle(headlineText)
+ },
+ modifier = modifier,
+ enabled = enabled,
+ onLongClick = onLongClick,
+ overlineContent = overlineContent,
+ supportingContent = supportingContent,
+ leadingContent = leadingContent,
+ trailingContent = trailingContent,
+ tonalElevation = tonalElevation,
+ shape = shape,
+ colors = colors,
+ scale = scale,
+ border = border,
+ glow = glow,
+ interactionSource = interactionSource,
+)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt
new file mode 100644
index 00000000..1185f719
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt
@@ -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(null) }
+ var searchForDialog by remember { mutableStateOf(null) }
+
+ val state by viewModel.state.collectAsState()
+ var position by rememberPosition(0, 0)
+ // TODO discover rows
+ val discoverEnabled = false // by viewModel.discoverEnabled.collectAsState(false)
+
+ // Adds a row, waits until its done loading, then scrolls to the new row
+ fun addRow(
+ scrollToBottom: Boolean = true,
+ func: () -> Job,
+ ) {
+ scope.launch(ExceptionHandler(autoToast = true)) {
+ while (backStack.size > 1) {
+ backStack.removeAt(backStack.lastIndex)
+ }
+ func.invoke().join()
+ if (scrollToBottom) {
+ listState.animateScrollToItem(state.rows.lastIndex)
+ }
+ }
+ }
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = modifier,
+ ) {
+ Box(
+ modifier =
+ Modifier
+ .width(settingsWidth)
+ .fillMaxHeight()
+ .background(color = MaterialTheme.colorScheme.surface),
+ ) {
+ NavDisplay(
+ backStack = backStack,
+// onBack = { navigationManager.goBack() },
+ entryDecorators =
+ listOf(
+ rememberSaveableStateHolderNavEntryDecorator(),
+ rememberViewModelStoreNavEntryDecorator(),
+ ),
+ modifier = Modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)),
+ entryProvider = { key ->
+ val dest = key as HomeSettingsDestination
+ NavEntry(dest, contentKey = key.toString()) {
+ val destModifier =
+ Modifier
+ .fillMaxSize()
+ .padding(8.dp)
+ when (dest) {
+ HomeSettingsDestination.RowList -> {
+ HomeSettingsRowList(
+ state = state,
+ onClickAdd = { backStack.add(HomeSettingsDestination.AddRow) },
+ onClickSettings = { backStack.add(HomeSettingsDestination.GlobalSettings) },
+ onClickPresets = { backStack.add(HomeSettingsDestination.Presets) },
+ onClickMove = viewModel::moveRow,
+ onClickDelete = viewModel::deleteRow,
+ onClick = { index, row ->
+ backStack.add(RowSettings(row.id))
+ scope.launch(ExceptionHandler()) {
+ Timber.v("Scroll to $index")
+ listState.animateScrollToItem(index)
+ // Update backdrop to first item in the row
+ (state.rowData.getOrNull(index) as? HomeRowLoadingState.Success)
+ ?.items
+ ?.firstOrNull()
+ ?.let {
+ viewModel.updateBackdrop(it)
+ }
+ position = RowColumn(index, 0)
+ }
+ },
+ modifier = destModifier,
+ )
+ }
+
+ is HomeSettingsDestination.AddRow -> {
+ HomeSettingsAddRow(
+ libraries = state.libraries,
+ showDiscover = discoverEnabled,
+ onClick = { backStack.add(ChooseRowType(it)) },
+ onClickMeta = {
+ when (it) {
+ MetaRowType.CONTINUE_WATCHING,
+ MetaRowType.NEXT_UP,
+ MetaRowType.COMBINED_CONTINUE_WATCHING,
+ -> {
+ addRow { viewModel.addRow(it) }
+ }
+
+ MetaRowType.FAVORITES -> {
+ backStack.add(HomeSettingsDestination.ChooseFavorite)
+ }
+
+ MetaRowType.DISCOVER -> {
+ backStack.add(HomeSettingsDestination.ChooseDiscover)
+ }
+
+ MetaRowType.COLLECTION -> {
+ searchForDialog = BaseItemKind.BOX_SET
+ }
+
+ MetaRowType.PLAYLIST -> {
+ searchForDialog = BaseItemKind.PLAYLIST
+ }
+ }
+ },
+ modifier = destModifier,
+ )
+ }
+
+ is ChooseRowType -> {
+ HomeLibraryRowTypeList(
+ library = dest.library,
+ onClick = { type ->
+ when (type) {
+ LibraryRowType.COLLECTION -> {
+ searchForDialog = BaseItemKind.BOX_SET
+ }
+
+ LibraryRowType.PLAYLIST -> {
+ searchForDialog = BaseItemKind.PLAYLIST
+ }
+
+ else -> {
+ addRow { viewModel.addRow(dest.library, type) }
+ }
+ }
+ },
+ modifier = destModifier,
+ )
+ }
+
+ is RowSettings -> {
+ val row =
+ state.rows
+ .first { it.id == dest.rowId }
+ val preferenceOptions =
+ remember(row.config) {
+ when (row.config) {
+ is HomeRowConfig.ContinueWatching,
+ is HomeRowConfig.ContinueWatchingCombined,
+ -> Options.OPTIONS_EPISODES
+
+ is HomeRowConfig.Genres -> Options.GENRE_OPTIONS
+
+ else -> Options.OPTIONS
+ }
+ }
+ val defaultViewOptions =
+ remember(row.config) {
+ when (row.config) {
+ is HomeRowConfig.Genres -> HomeRowViewOptions.genreDefault
+ else -> HomeRowViewOptions()
+ }
+ }
+ HomeRowSettings(
+ title = row.title,
+ preferenceOptions = preferenceOptions,
+ viewOptions = row.config.viewOptions,
+ defaultViewOptions = defaultViewOptions,
+ onViewOptionsChange = {
+ viewModel.updateViewOptions(dest.rowId, it)
+ },
+ onApplyApplyAll = {
+ viewModel.updateViewOptionsForAll(row.config.viewOptions)
+ },
+ modifier = destModifier,
+ )
+ }
+
+ HomeSettingsDestination.ChooseDiscover -> {
+ TODO()
+ }
+
+ HomeSettingsDestination.ChooseFavorite -> {
+ HomeSettingsFavoriteList(
+ onClick = { type ->
+ addRow { viewModel.addFavoriteRow(type) }
+ },
+ )
+ }
+
+ HomeSettingsDestination.GlobalSettings -> {
+ val preferences by
+ viewModel.preferencesDataStore.data.collectAsState(
+ AppPreferences.getDefaultInstance(),
+ )
+
+ HomeSettingsGlobal(
+ preferences = preferences,
+ onPreferenceChange = { newPrefs ->
+ scope.launchIO {
+ viewModel.preferencesDataStore.updateData { newPrefs }
+ }
+ },
+ onClickResize = { viewModel.resizeCards(it) },
+ onClickSave = {
+ showConfirmDialog =
+ ShowConfirm(R.string.overwrite_server_settings) {
+ viewModel.saveToRemote()
+ }
+ },
+ onClickLoad = {
+ showConfirmDialog =
+ ShowConfirm(R.string.overwrite_local_settings) {
+ viewModel.loadFromRemote()
+ }
+ },
+ onClickLoadWeb = {
+ showConfirmDialog =
+ ShowConfirm(R.string.overwrite_local_settings) {
+ viewModel.loadFromRemoteWeb()
+ }
+ },
+ onClickReset = {
+ showConfirmDialog =
+ ShowConfirm(R.string.overwrite_local_settings) {
+ addRow(false) { viewModel.resetToDefault() }
+ }
+ },
+ modifier = destModifier,
+ )
+ }
+
+ HomeSettingsDestination.Presets -> {
+ HomeRowPresetsContent(
+ onApply = viewModel::applyPreset,
+ modifier = destModifier,
+ )
+ }
+ }
+ }
+ },
+ )
+ }
+ HomePageContent(
+ loadingState = state.loading,
+ homeRows = state.rowData,
+ position = position,
+ onFocusPosition = { position = it },
+ onClickItem = { _, _ -> },
+ onLongClickItem = { _, _ -> },
+ onClickPlay = { _, _ -> },
+ showClock = false,
+ onUpdateBackdrop = viewModel::updateBackdrop,
+ listState = listState,
+ takeFocus = false,
+ showEmptyRows = true,
+ modifier =
+ Modifier
+ .fillMaxHeight()
+ .weight(1f),
+ )
+ }
+ showConfirmDialog?.let { (body, onConfirm) ->
+ ConfirmDialog(
+ title = stringResource(R.string.confirm),
+ body = stringResource(body),
+ onCancel = { showConfirmDialog = null },
+ onConfirm = {
+ onConfirm.invoke()
+ showConfirmDialog = null
+ },
+ )
+ }
+ searchForDialog?.let { searchType ->
+ SearchForDialog(
+ onDismissRequest = { searchForDialog = null },
+ searchType = searchType,
+ onClick = {
+ searchForDialog = null
+ addRow { viewModel.addRow(searchType, it) }
+ },
+ )
+ }
+}
+
+data class ShowConfirm(
+ @param:StringRes val body: Int,
+ val onConfirm: () -> Unit,
+)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt
new file mode 100644
index 00000000..9eadc615
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt
@@ -0,0 +1,269 @@
+package com.github.damontecres.wholphin.ui.main.settings
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.wrapContentWidth
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.NonRestartableComposable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusDirection
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.focusRestorer
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.Icon
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.services.HomeRowConfigDisplay
+import com.github.damontecres.wholphin.ui.FontAwesome
+import com.github.damontecres.wholphin.ui.components.Button
+import com.github.damontecres.wholphin.ui.rememberInt
+import com.github.damontecres.wholphin.ui.tryRequestFocus
+import kotlinx.coroutines.launch
+
+enum class MoveDirection {
+ UP,
+ DOWN,
+}
+
+@Composable
+fun HomeSettingsRowList(
+ state: HomePageSettingsState,
+ onClick: (Int, HomeRowConfigDisplay) -> Unit,
+ onClickAdd: () -> Unit,
+ onClickSettings: () -> Unit,
+ onClickPresets: () -> Unit,
+ onClickMove: (MoveDirection, Int) -> Unit,
+ onClickDelete: (Int) -> Unit,
+ modifier: Modifier,
+) {
+ val focusManager = LocalFocusManager.current
+ val scope = rememberCoroutineScope()
+ val listState = rememberLazyListState()
+
+ val itemsBeforeRows = 4
+ val focusRequesters =
+ remember(state.rows.size) { List(itemsBeforeRows + state.rows.size) { FocusRequester() } }
+
+ var position by rememberInt(0)
+
+ LaunchedEffect(Unit) {
+ focusRequesters.getOrNull(position)?.tryRequestFocus()
+ }
+ Column(modifier = modifier) {
+ TitleText(stringResource(R.string.customize_home))
+ LazyColumn(
+ state = listState,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier =
+ modifier
+ .fillMaxHeight()
+ .focusRestorer(focusRequesters[0]),
+ ) {
+ item {
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = stringResource(R.string.add_row),
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = null,
+ )
+ },
+ onClick = {
+ position = 0
+ onClickAdd.invoke()
+ },
+ modifier = Modifier.focusRequester(focusRequesters[0]),
+ )
+ }
+ item {
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = stringResource(R.string.settings),
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Default.Settings,
+ contentDescription = null,
+ )
+ },
+ onClick = {
+ position = 1
+ onClickSettings.invoke()
+ },
+ modifier = Modifier.focusRequester(focusRequesters[1]),
+ )
+ }
+ item {
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = stringResource(R.string.display_presets),
+ supportingContent = {
+ Text(
+ text = stringResource(R.string.display_presets_description),
+ )
+ },
+ leadingContent = {
+ Text(
+ text = stringResource(R.string.fa_sliders),
+ fontFamily = FontAwesome,
+ )
+ },
+ onClick = {
+ position = 2
+ onClickPresets.invoke()
+ },
+ modifier = Modifier.focusRequester(focusRequesters[2]),
+ )
+ }
+ item {
+ TitleText(stringResource(R.string.home_rows) + " (${state.rows.size})")
+ HorizontalDivider()
+ }
+ itemsIndexed(state.rows, key = { _, row -> row.id }) { index, row ->
+ HomeRowConfigContent(
+ config = row,
+ moveUpAllowed = index > 0,
+ moveDownAllowed = index != state.rows.lastIndex,
+ deleteAllowed = state.rows.size > 1,
+ onClickMove = {
+ onClickMove.invoke(it, index)
+ scope.launch {
+ val scrollIndex =
+ itemsBeforeRows + if (it == MoveDirection.UP) index - 1 else index + 1
+ if (scrollIndex < listState.firstVisibleItemIndex ||
+ scrollIndex > listState.layoutInfo.visibleItemsInfo.lastIndex
+ ) {
+ listState.animateScrollToItem(scrollIndex)
+ }
+ }
+ },
+ onClickDelete = {
+ if (index != state.rows.lastIndex) {
+ focusManager.moveFocus(FocusDirection.Down)
+ } else {
+ focusManager.moveFocus(FocusDirection.Up)
+ }
+ onClickDelete.invoke(index)
+ },
+ onClick = {
+ position = itemsBeforeRows + index
+ onClick.invoke(index, row)
+ },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .animateItem()
+ .focusRequester(focusRequesters[itemsBeforeRows + index]),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun HomeRowConfigContent(
+ config: HomeRowConfigDisplay,
+ moveUpAllowed: Boolean,
+ moveDownAllowed: Boolean,
+ deleteAllowed: Boolean,
+ onClick: () -> Unit,
+ onClickMove: (MoveDirection) -> Unit,
+ onClickDelete: () -> Unit,
+ modifier: Modifier,
+) {
+ Box(
+ modifier = modifier,
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .heightIn(min = 40.dp, max = 88.dp),
+ ) {
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = config.title,
+ onClick = onClick,
+ modifier = Modifier.weight(1f),
+ )
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ modifier = Modifier.wrapContentWidth(),
+ ) {
+ Button(
+ onClick = { onClickMove.invoke(MoveDirection.UP) },
+ enabled = moveUpAllowed,
+ ) {
+ Text(
+ text = stringResource(R.string.fa_caret_up),
+ fontFamily = FontAwesome,
+ )
+ }
+ Button(
+ onClick = { onClickMove.invoke(MoveDirection.DOWN) },
+ enabled = moveDownAllowed,
+ ) {
+ Text(
+ text = stringResource(R.string.fa_caret_down),
+ fontFamily = FontAwesome,
+ )
+ }
+ Button(
+ onClick = onClickDelete,
+ enabled = deleteAllowed,
+ ) {
+ Icon(
+ imageVector = Icons.Default.Delete,
+ contentDescription = "delete",
+ modifier = Modifier,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+@NonRestartableComposable
+fun TitleText(
+ title: String,
+ modifier: Modifier = Modifier,
+) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Start,
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp, bottom = 4.dp),
+ )
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt
new file mode 100644
index 00000000..67b57e28
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt
@@ -0,0 +1,754 @@
+package com.github.damontecres.wholphin.ui.main.settings
+
+import android.content.Context
+import android.widget.Toast
+import androidx.compose.runtime.Immutable
+import androidx.datastore.core.DataStore
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.data.ServerRepository
+import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.data.model.HomePageSettings
+import com.github.damontecres.wholphin.data.model.HomeRowConfig
+import com.github.damontecres.wholphin.data.model.HomeRowConfig.ContinueWatching
+import com.github.damontecres.wholphin.data.model.HomeRowConfig.ContinueWatchingCombined
+import com.github.damontecres.wholphin.data.model.HomeRowConfig.Genres
+import com.github.damontecres.wholphin.data.model.HomeRowConfig.NextUp
+import com.github.damontecres.wholphin.data.model.HomeRowConfig.RecentlyAdded
+import com.github.damontecres.wholphin.data.model.HomeRowConfig.RecentlyReleased
+import com.github.damontecres.wholphin.data.model.HomeRowConfig.Suggestions
+import com.github.damontecres.wholphin.data.model.HomeRowConfig.TvChannels
+import com.github.damontecres.wholphin.data.model.HomeRowConfig.TvPrograms
+import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
+import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION
+import com.github.damontecres.wholphin.preferences.AppPreferences
+import com.github.damontecres.wholphin.services.BackdropService
+import com.github.damontecres.wholphin.services.HomePageResolvedSettings
+import com.github.damontecres.wholphin.services.HomeRowConfigDisplay
+import com.github.damontecres.wholphin.services.HomeSettingsService
+import com.github.damontecres.wholphin.services.NavDrawerService
+import com.github.damontecres.wholphin.services.SeerrServerRepository
+import com.github.damontecres.wholphin.services.UnsupportedHomeSettingsVersionException
+import com.github.damontecres.wholphin.services.UserPreferencesService
+import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
+import com.github.damontecres.wholphin.services.tvAccess
+import com.github.damontecres.wholphin.ui.launchIO
+import com.github.damontecres.wholphin.ui.showToast
+import com.github.damontecres.wholphin.util.HomeRowLoadingState
+import com.github.damontecres.wholphin.util.LoadingState
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.sync.Semaphore
+import kotlinx.coroutines.sync.withPermit
+import kotlinx.serialization.Serializable
+import org.jellyfin.sdk.api.client.ApiClient
+import org.jellyfin.sdk.api.client.extensions.userLibraryApi
+import org.jellyfin.sdk.model.api.BaseItemKind
+import org.jellyfin.sdk.model.api.CollectionType
+import org.jellyfin.sdk.model.serializer.UUIDSerializer
+import timber.log.Timber
+import java.util.UUID
+import javax.inject.Inject
+import kotlin.properties.Delegates
+
+@HiltViewModel
+class HomeSettingsViewModel
+ @Inject
+ constructor(
+ @param:ApplicationContext private val context: Context,
+ private val api: ApiClient,
+ private val homeSettingsService: HomeSettingsService,
+ private val serverRepository: ServerRepository,
+ private val userPreferencesService: UserPreferencesService,
+ private val navDrawerService: NavDrawerService,
+ private val backdropService: BackdropService,
+ private val seerrServerRepository: SeerrServerRepository,
+ val preferencesDataStore: DataStore,
+ @param:IoCoroutineScope private val ioScope: CoroutineScope,
+ ) : ViewModel() {
+ private val _state = MutableStateFlow(HomePageSettingsState.EMPTY)
+ val state: StateFlow = _state
+
+ private var idCounter by Delegates.notNull()
+
+ val discoverEnabled = seerrServerRepository.active
+
+ private var originalLocalSettings: HomePageSettings? = null
+ private var originalRemoteSettings: HomePageSettings? = null
+
+ init {
+ addCloseable { saveToLocal() }
+ viewModelScope.launchIO {
+ val userDto = serverRepository.currentUserDto.value ?: return@launchIO
+ val libraries = navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess)
+ val currentSettings =
+ homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY }
+ originalLocalSettings = homeSettingsService.loadFromLocal(userDto.id)
+ originalRemoteSettings = homeSettingsService.loadFromServer(userDto.id)
+ Timber.v("currentSettings=%s", currentSettings)
+ idCounter = currentSettings.rows.maxOfOrNull { it.id }?.plus(1) ?: 0
+ _state.update {
+ it.copy(
+ libraries = libraries,
+ rows = currentSettings.rows,
+ )
+ }
+ fetchRowData()
+ }
+ }
+
+ fun updateBackdrop(item: BaseItem) {
+ viewModelScope.launchIO {
+ backdropService.submit(item)
+ }
+ }
+
+ private suspend fun fetchRowData() {
+ val limit = 8
+ val semaphore = Semaphore(4)
+ val rows =
+ serverRepository.currentUserDto.value?.let { userDto ->
+ val prefs = userPreferencesService.getCurrent().appPreferences.homePagePreferences
+ state.value
+ .let { state ->
+ state.rows
+ .map { it.config }
+ .map { row ->
+ viewModelScope.async(Dispatchers.IO) {
+ semaphore.withPermit {
+ homeSettingsService.fetchDataForRow(
+ row = row,
+ scope = viewModelScope,
+ prefs = prefs,
+ userDto = userDto,
+ libraries = state.libraries,
+ limit = limit,
+ isRefresh = false,
+ )
+ }
+ }
+ }
+ }.awaitAll()
+ }
+ rows?.let { rows ->
+ rows
+ .firstOrNull { it is HomeRowLoadingState.Success && it.items.isNotEmpty() }
+ ?.let {
+ it as HomeRowLoadingState.Success
+ it.items.firstOrNull()?.let {
+ Timber.v("Updating backdrop")
+ updateBackdrop(it)
+ }
+ }
+ updateState {
+ it.copy(loading = LoadingState.Success, rowData = rows)
+ }
+ }
+ }
+
+ private fun List.move(
+ direction: MoveDirection,
+ index: Int,
+ ): List =
+ toMutableList().apply {
+ if (direction == MoveDirection.DOWN) {
+ val down = this[index]
+ val up = this[index + 1]
+ set(index, up)
+ set(index + 1, down)
+ } else {
+ val up = this[index]
+ val down = this[index - 1]
+ set(index - 1, up)
+ set(index, down)
+ }
+ }
+
+ fun moveRow(
+ direction: MoveDirection,
+ index: Int,
+ ) {
+ viewModelScope.launchIO {
+ updateState {
+ val rows = it.rows.move(direction, index)
+ val rowData = it.rowData.move(direction, index)
+ it.copy(
+ rows = rows,
+ rowData = rowData,
+ )
+ }
+ }
+// viewModelScope.launchIO { fetchRowData() }
+ }
+
+ fun deleteRow(index: Int) {
+ viewModelScope.launchIO {
+ updateState {
+ val rows = it.rows.toMutableList().apply { removeAt(index) }
+ val rowData = it.rowData.toMutableList().apply { removeAt(index) }
+ it.copy(
+ rows = rows,
+ rowData = rowData,
+ )
+ }
+ }
+ }
+
+ fun addRow(type: MetaRowType): Job =
+ viewModelScope.launchIO {
+ val id = idCounter++
+ val newRow =
+ when (type) {
+ MetaRowType.CONTINUE_WATCHING -> {
+ HomeRowConfigDisplay(
+ id = id,
+ title = context.getString(R.string.continue_watching),
+ config = ContinueWatching(),
+ )
+ }
+
+ MetaRowType.NEXT_UP -> {
+ HomeRowConfigDisplay(
+ id = id,
+ title = context.getString(R.string.next_up),
+ config = NextUp(),
+ )
+ }
+
+ MetaRowType.COMBINED_CONTINUE_WATCHING -> {
+ HomeRowConfigDisplay(
+ id = id,
+ title = context.getString(R.string.combine_continue_next),
+ config = ContinueWatchingCombined(),
+ )
+ }
+
+ MetaRowType.FAVORITES,
+ MetaRowType.COLLECTION,
+ MetaRowType.PLAYLIST,
+ -> {
+ throw IllegalArgumentException("Should use a different addRow() instead")
+ }
+
+ MetaRowType.DISCOVER -> {
+ TODO()
+ }
+ }
+ updateState {
+ it.copy(
+ loading = LoadingState.Loading,
+ rows = it.rows.toMutableList().apply { add(newRow) },
+ )
+ }
+ fetchRowData()
+ }
+
+ fun addRow(
+ library: Library,
+ rowType: LibraryRowType,
+ ): Job =
+ viewModelScope.launchIO {
+ val id = idCounter++
+ val newRow =
+ when (rowType) {
+ LibraryRowType.RECENTLY_ADDED -> {
+ val title =
+ library.name.let { context.getString(R.string.recently_added_in, it) }
+ HomeRowConfigDisplay(
+ id = id,
+ title = title,
+ config = RecentlyAdded(library.itemId),
+ )
+ }
+
+ LibraryRowType.RECENTLY_RELEASED -> {
+ val title =
+ library.name.let {
+ context.getString(
+ R.string.recently_released_in,
+ it,
+ )
+ }
+ HomeRowConfigDisplay(
+ id = id,
+ title = title,
+ config = RecentlyReleased(library.itemId),
+ )
+ }
+
+ LibraryRowType.GENRES -> {
+ val title = library.name.let { context.getString(R.string.genres_in, it) }
+ HomeRowConfigDisplay(
+ id = id,
+ title = title,
+ config = Genres(library.itemId),
+ )
+ }
+
+ LibraryRowType.SUGGESTIONS -> {
+ val title =
+ library.name.let { context.getString(R.string.suggestions_for, it) }
+ HomeRowConfigDisplay(
+ id = id,
+ title = title,
+ config = Suggestions(library.itemId),
+ )
+ }
+
+ LibraryRowType.TV_CHANNELS -> {
+ val title = context.getString(R.string.channels)
+ HomeRowConfigDisplay(
+ id = id,
+ title = title,
+ config =
+ TvChannels(
+ viewOptions = HomeRowViewOptions.liveTvDefault,
+ ),
+ )
+ }
+
+ LibraryRowType.TV_PROGRAMS -> {
+ val title = context.getString(R.string.watch_live)
+ HomeRowConfigDisplay(
+ id = id,
+ title = title,
+ config = TvPrograms(),
+ )
+ }
+
+ LibraryRowType.RECENTLY_RECORDED -> {
+ val title = context.getString(R.string.recently_recorded)
+ HomeRowConfigDisplay(
+ id = id,
+ title = title,
+ config = RecentlyAdded(library.itemId),
+ )
+ }
+
+ LibraryRowType.COLLECTION,
+ LibraryRowType.PLAYLIST,
+ -> {
+ throw IllegalArgumentException("Use different addRow")
+ }
+ }
+ updateState {
+ it.copy(
+ loading = LoadingState.Loading,
+ rows = it.rows.toMutableList().apply { add(newRow) },
+ )
+ }
+ fetchRowData()
+ }
+
+ fun addFavoriteRow(type: BaseItemKind): Job =
+ viewModelScope.launchIO {
+ Timber.v("Adding favorite row for $type")
+ val id = idCounter++
+ val newRow =
+ HomeRowConfigDisplay(
+ id = id,
+ title =
+ context.getString(
+ R.string.favorite_items,
+ context.getString(favoriteOptions[type]!!),
+ ),
+ config = HomeRowConfig.Favorite(type),
+ )
+ updateState {
+ it.copy(
+ loading = LoadingState.Loading,
+ rows = it.rows.toMutableList().apply { add(newRow) },
+ )
+ }
+ fetchRowData()
+ }
+
+ fun addRow(
+ type: BaseItemKind,
+ parent: BaseItem,
+ ) = viewModelScope.launchIO {
+ Timber.v("Adding %s row for %s", type, parent.id)
+ val id = idCounter++
+ val newRow =
+ HomeRowConfigDisplay(
+ id = id,
+ title = parent.name ?: "",
+ config =
+ HomeRowConfig.ByParent(
+ parentId = parent.id,
+ recursive = true,
+ ),
+ )
+ updateState {
+ it.copy(
+ loading = LoadingState.Loading,
+ rows = it.rows.toMutableList().apply { add(newRow) },
+ )
+ }
+ fetchRowData()
+ }
+
+ fun updateViewOptions(
+ rowId: Int,
+ viewOptions: HomeRowViewOptions,
+ ) {
+ viewModelScope.launchIO {
+ var fetchData = false
+ updateState {
+ val index = it.rows.indexOfFirst { it.id == rowId }
+ val config = it.rows[index].config
+ val newRowConfig = config.updateViewOptions(viewOptions)
+ val newRow = it.rows[index].copy(config = newRowConfig)
+ if (config.viewOptions.useSeries != viewOptions.useSeries) {
+ fetchData = true
+ }
+ it.copy(
+ rows =
+ it.rows.toMutableList().apply {
+ set(index, newRow)
+ },
+ rowData =
+ it.rowData.toMutableList().apply {
+ val row = it.rowData[index]
+ val newRow =
+ if (row is HomeRowLoadingState.Success) {
+ row.copy(viewOptions = viewOptions)
+ } else {
+ row
+ }
+ set(index, newRow)
+ },
+ )
+ }
+ if (fetchData) {
+ fetchRowData()
+ }
+ }
+ }
+
+ fun updateViewOptionsForAll(viewOptions: HomeRowViewOptions) {
+ viewModelScope.launchIO {
+ updateState {
+ it.copy(
+ rowData =
+ it.rowData.toMutableList().map { row ->
+ if (row is HomeRowLoadingState.Success) {
+ row.copy(viewOptions = viewOptions)
+ } else {
+ row
+ }
+ },
+ )
+ }
+ }
+ }
+
+ fun saveToRemote() {
+ viewModelScope.launchIO {
+ serverRepository.currentUser.value?.let { user ->
+ Timber.d("Saving home settings to remote")
+ val rows = state.value.rows.map { it.config }
+ val settings =
+ HomePageSettings(rows = rows, SUPPORTED_HOME_PAGE_SETTINGS_VERSION)
+ try {
+ Timber.d("saveToRemote")
+ homeSettingsService.saveToServer(user.id, settings)
+ showSaveToast()
+ } catch (ex: Exception) {
+ Timber.e(ex)
+ showToast(context, "Error saving: ${ex.localizedMessage}")
+ }
+ }
+ }
+ }
+
+ fun loadFromRemote() {
+ viewModelScope.launchIO {
+ serverRepository.currentUser.value?.let { user ->
+ Timber.d("Loading home settings from remote")
+ try {
+ _state.update { it.copy(loading = LoadingState.Loading) }
+ val result = homeSettingsService.loadFromServer(user.id)
+ if (result != null) {
+ Timber.v("Got remote settings")
+ val newRows =
+ result.rows.mapIndexed { index, config ->
+ homeSettingsService.resolve(index, config)
+ }
+ idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0
+ _state.update {
+ it.copy(rows = newRows)
+ }
+ } else {
+ Timber.v("No remote settings")
+ showToast(context, "No server-side settings found")
+ }
+ fetchRowData()
+ } catch (ex: UnsupportedHomeSettingsVersionException) {
+ // TODO
+ Timber.w(ex)
+ showToast(context, "Error: ${ex.localizedMessage}")
+ } catch (ex: Exception) {
+ Timber.e(ex)
+ showToast(context, "Error: ${ex.localizedMessage}")
+ }
+ }
+ }
+ }
+
+ fun loadFromRemoteWeb() {
+ viewModelScope.launchIO {
+ serverRepository.currentUser.value?.let { user ->
+ Timber.d("Loading home settings from web")
+ try {
+ _state.update { it.copy(loading = LoadingState.Loading) }
+ val result = homeSettingsService.parseFromWebConfig(user.id)
+ if (result != null) {
+ Timber.v("Got web settings")
+ idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0
+ _state.update {
+ it.copy(rows = result.rows)
+ }
+ } else {
+ Timber.v("No web settings")
+ showToast(context, "No server-side web settings found")
+ }
+ fetchRowData()
+ } catch (ex: Exception) {
+ Timber.e(ex)
+ showToast(context, "Error: ${ex.localizedMessage}")
+ }
+ }
+ }
+ }
+
+ fun saveToLocal() {
+ // This uses injected ioScope so that it will still run when the page is closing
+ ioScope.launchIO {
+ serverRepository.currentUser.value?.let { user ->
+ val rows = state.value.rows.map { it.config }
+ val settings =
+ HomePageSettings(rows = rows, SUPPORTED_HOME_PAGE_SETTINGS_VERSION)
+ try {
+ Timber.d("saveToLocal")
+ // Only save if there are changes based on original source
+ val shouldSave =
+ if (originalLocalSettings != null) {
+ originalLocalSettings != settings
+ } else if (originalRemoteSettings != null) {
+ originalRemoteSettings != settings
+ } else {
+ true
+ }
+ if (shouldSave) {
+ homeSettingsService.saveToLocal(user.id, settings)
+ homeSettingsService.updateCurrent(settings)
+ showSaveToast()
+ } else {
+ Timber.d("No changes")
+ }
+ } catch (ex: UnsupportedHomeSettingsVersionException) {
+ Timber.w(ex, "Overwriting local settings")
+ homeSettingsService.saveToLocal(user.id, settings)
+ showSaveToast()
+ } catch (ex: Exception) {
+ Timber.e(ex)
+ showToast(context, "Error saving: ${ex.localizedMessage}")
+ }
+ }
+ }
+ }
+
+ private fun updateState(update: (HomePageSettingsState) -> HomePageSettingsState) {
+ _state.update {
+ update.invoke(it)
+ }
+ homeSettingsService.currentSettings.update { HomePageResolvedSettings(state.value.rows) }
+ }
+
+ fun resizeCards(relative: Int) {
+ viewModelScope.launchIO {
+ updateState {
+ val newRows =
+ it.rows.toMutableList().map { row ->
+ val vo = row.config.viewOptions
+ val newVo = vo.copy(heightDp = vo.heightDp + (4 * relative))
+ row.copy(config = row.config.updateViewOptions(newVo))
+ }
+ it.copy(
+ rows = newRows,
+ rowData =
+ it.rowData.toMutableList().mapIndexed { index, row ->
+ if (row is HomeRowLoadingState.Success) {
+ row.copy(viewOptions = newRows[index].config.viewOptions)
+ } else {
+ row
+ }
+ },
+ )
+ }
+ }
+ }
+
+ fun resetToDefault() =
+ viewModelScope.launchIO {
+ val userId = serverRepository.currentUser.value?.id ?: return@launchIO
+ _state.update { it.copy(loading = LoadingState.Loading) }
+ val result = homeSettingsService.createDefault(userId)
+ idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0
+ _state.update {
+ it.copy(rows = result.rows, rowData = emptyList())
+ }
+ fetchRowData()
+ }
+
+ private suspend fun showSaveToast() =
+ showToast(
+ context,
+ context.getString(R.string.settings_saved),
+ Toast.LENGTH_SHORT,
+ )
+
+ fun applyPreset(preset: HomeRowPresets) {
+ _state.update { it.copy(loading = LoadingState.Loading) }
+ viewModelScope.launchIO {
+ val state = state.value
+
+ val typeCache = mutableMapOf()
+
+ suspend fun getCollectionType(itemId: UUID): CollectionType =
+ typeCache.getOrPut(itemId) {
+ state.libraries
+ .firstOrNull { it.itemId == itemId }
+ ?.collectionType
+ ?: api.userLibraryApi
+ .getItem(itemId)
+ .content.collectionType ?: CollectionType.UNKNOWN
+ } ?: CollectionType.UNKNOWN
+
+ val newRows =
+ state.rows.map {
+ val newConfig =
+ when (it.config) {
+ is ContinueWatching,
+ is NextUp,
+ is ContinueWatchingCombined,
+ -> {
+ it.config.updateViewOptions(preset.continueWatching)
+ }
+
+ is HomeRowConfig.ByParent -> {
+ val collectionType = getCollectionType(it.config.parentId)
+ val viewOptions = preset.getByCollectionType(collectionType)
+ it.config.updateViewOptions(viewOptions)
+ }
+
+ is HomeRowConfig.Favorite -> {
+ val viewOptions =
+ when (it.config.kind) {
+ BaseItemKind.MOVIE -> preset.movieLibrary
+ BaseItemKind.SERIES -> preset.tvLibrary
+ BaseItemKind.EPISODE -> preset.continueWatching
+ BaseItemKind.VIDEO -> preset.videoLibrary
+ BaseItemKind.PLAYLIST -> preset.playlist
+ BaseItemKind.PERSON -> preset.movieLibrary
+ else -> preset.movieLibrary
+ }
+ it.config.updateViewOptions(viewOptions)
+ }
+
+ is Genres -> {
+ it.config.updateViewOptions(it.config.viewOptions.copy(heightDp = preset.genreSize))
+ }
+
+ is HomeRowConfig.GetItems -> {
+ it.config
+ }
+
+ is RecentlyAdded -> {
+ val collectionType = getCollectionType(it.config.parentId)
+ val viewOptions = preset.getByCollectionType(collectionType)
+ it.config.updateViewOptions(viewOptions)
+ }
+
+ is RecentlyReleased -> {
+ val collectionType = getCollectionType(it.config.parentId)
+ val viewOptions = preset.getByCollectionType(collectionType)
+ it.config.updateViewOptions(viewOptions)
+ }
+
+ is HomeRowConfig.Recordings -> {
+ it.config.updateViewOptions(preset.tvLibrary)
+ }
+
+ is Suggestions -> {
+ val collectionType = getCollectionType(it.config.parentId)
+ val viewOptions = preset.getByCollectionType(collectionType)
+ it.config.updateViewOptions(viewOptions)
+ }
+
+ is HomeRowConfig.TvPrograms -> {
+ it.config.updateViewOptions(preset.liveTv)
+ }
+
+ is HomeRowConfig.TvChannels -> {
+ it.config.updateViewOptions(preset.liveTv)
+ }
+ }
+ it.copy(config = newConfig)
+ }
+
+ _state.update {
+ it.copy(
+ loading = LoadingState.Success,
+ rows = newRows,
+ rowData =
+ it.rowData.toMutableList().mapIndexed { index, row ->
+ if (row is HomeRowLoadingState.Success) {
+ row.copy(viewOptions = newRows[index].config.viewOptions)
+ } else {
+ row
+ }
+ },
+ )
+ }
+ }
+ }
+ }
+
+data class HomePageSettingsState(
+ val loading: LoadingState,
+ val rows: List,
+ val rowData: List,
+ val libraries: List,
+) {
+ companion object {
+ val EMPTY =
+ HomePageSettingsState(
+ LoadingState.Pending,
+ emptyList(),
+ emptyList(),
+ emptyList(),
+ )
+ }
+}
+
+@Immutable
+@Serializable
+data class Library(
+ @Serializable(UUIDSerializer::class) val itemId: UUID,
+ val name: String,
+ val type: BaseItemKind,
+ val collectionType: CollectionType,
+ val isRecordingFolder: Boolean,
+)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt
index 6d793ed4..936ce69f 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt
@@ -33,6 +33,9 @@ sealed class Destination(
val id: Long = 0L,
) : Destination()
+ @Serializable
+ data object HomeSettings : Destination(true)
+
@Serializable
data class Settings(
val screen: PreferenceScreenOption,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt
index e94e6b38..87ec465e 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt
@@ -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)
}
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt
index 37d9b250..f0276ffd 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt
@@ -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>(null)
- val libraries = MutableLiveData>(listOf())
+ val state = navDrawerService.state
val selectedIndex = MutableLiveData(-1)
- val showMore = MutableLiveData(false)
+ val moreExpanded = MutableLiveData(false)
- init {
- seerrServerRepository.active
- .onEach {
- init()
- }.launchIn(viewModelScope)
+ fun onClickDrawerItem(
+ index: Int,
+ item: NavDrawerItem,
+ ) {
+ if (item !is NavDrawerItem.More) setShowMore(false)
+ when (item) {
+ NavDrawerItem.Favorites -> {
+ setIndex(index)
+ navigationManager.navigateToFromDrawer(
+ Destination.Favorites,
+ )
+ }
+
+ NavDrawerItem.More -> {
+ setShowMore(!moreExpanded.value!!)
+ }
+
+ NavDrawerItem.Discover -> {
+ setIndex(index)
+ navigationManager.navigateToFromDrawer(
+ Destination.Discover,
+ )
+ }
+
+ is ServerNavDrawerItem -> {
+ setIndex(index)
+ navigationManager.navigateToFromDrawer(item.destination)
+ }
+ }
}
- fun init() {
- viewModelScope.launchIO {
- val all = navDrawerItemRepository.getNavDrawerItems()
- val libraries = navDrawerItemRepository.getFilteredNavDrawerItems(all)
- val moreLibraries = all.toMutableList().apply { removeAll(libraries) }
+ fun setIndex(index: Int) {
+ selectedIndex.value = index
+ }
- withContext(Dispatchers.Main) {
- this@NavDrawerViewModel.moreLibraries.value = moreLibraries
- this@NavDrawerViewModel.libraries.value = libraries
- }
+ fun setShowMore(value: Boolean) {
+ moreExpanded.value = value
+ }
+
+ fun getUserImage(user: JellyfinUser): String = api.imageApi.getUserImageUrl(user.id)
+
+ fun updateSelectedIndex() {
+ viewModelScope.launchDefault {
val asDestinations =
(
- libraries +
+ state.value.items +
listOf(
NavDrawerItem.More,
NavDrawerItem.Discover,
- ) + moreLibraries
+ ) + state.value.moreItems
).map {
if (it is ServerNavDrawerItem) {
it.destination
@@ -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,51 +393,83 @@ fun NavDrawer(
),
)
}
- itemsIndexed(libraries) { index, it ->
- val interactionSource = remember { MutableInteractionSource() }
- NavItem(
- library = it,
- selected = selectedIndex == index,
- moreExpanded = showMore,
- drawerOpen = isOpen,
- interactionSource = interactionSource,
- onClick = {
- viewModel.onClickDrawerItem(index, it)
- },
- modifier =
- Modifier
- .ifElse(
- selectedIndex == index,
- Modifier.focusRequester(focusRequester),
- ),
- )
- }
- if (showMore) {
- itemsIndexed(moreLibraries) { index, it ->
- val adjustedIndex = (index + libraries.size + 1)
+ itemsIndexed(state.items) { index, it ->
+ if (it !is NavDrawerItem.Discover || state.discoverEnabled) {
val interactionSource = remember { MutableInteractionSource() }
NavItem(
library = it,
- selected = selectedIndex == adjustedIndex,
- moreExpanded = showMore,
+ selected = selectedIndex == index,
+ moreExpanded = moreExpanded,
drawerOpen = isOpen,
- onClick = { viewModel.onClickDrawerItem(adjustedIndex, it) },
- containerColor =
- if (isOpen) {
- MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)
- } else {
- Color.Unspecified
- },
interactionSource = interactionSource,
+ onClick = {
+ viewModel.onClickDrawerItem(index, it)
+ },
modifier =
Modifier
.ifElse(
- selectedIndex == adjustedIndex,
+ selectedIndex == index,
Modifier.focusRequester(focusRequester),
),
)
}
}
+ if (state.moreItems.isNotEmpty()) {
+ item {
+ val index = state.items.size
+ val interactionSource = remember { MutableInteractionSource() }
+ NavItem(
+ library = NavDrawerItem.More,
+ selected = selectedIndex == index,
+ moreExpanded = moreExpanded,
+ drawerOpen = isOpen,
+ interactionSource = interactionSource,
+ onClick = {
+ viewModel.onClickDrawerItem(index, NavDrawerItem.More)
+ },
+ modifier =
+ Modifier
+ .ifElse(
+ selectedIndex == index,
+ Modifier.focusRequester(focusRequester),
+ ),
+ )
+ }
+ }
+ if (moreExpanded) {
+ itemsIndexed(state.moreItems) { index, it ->
+ val adjustedIndex =
+ remember(state) { (index + state.items.size + 1) }
+ if (it !is NavDrawerItem.Discover || state.discoverEnabled) {
+ val interactionSource = remember { MutableInteractionSource() }
+ NavItem(
+ library = it,
+ selected = selectedIndex == adjustedIndex,
+ moreExpanded = moreExpanded,
+ drawerOpen = isOpen,
+ onClick = {
+ viewModel.onClickDrawerItem(
+ adjustedIndex,
+ it,
+ )
+ },
+ containerColor =
+ if (isOpen) {
+ MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)
+ } else {
+ Color.Unspecified
+ },
+ interactionSource = interactionSource,
+ modifier =
+ Modifier
+ .ifElse(
+ selectedIndex == adjustedIndex,
+ Modifier.focusRequester(focusRequester),
+ ),
+ )
+ }
+ }
+ }
item {
val interactionSource = remember { MutableInteractionSource() }
IconNavItem(
@@ -637,10 +638,6 @@ fun NavigationDrawerScope.NavItem(
else -> R.string.fa_film
}
}
-
- NavDrawerItem.NowPlaying -> {
- R.string.fa_circle_play
- }
}
}
val focused by interactionSource.collectIsFocusedAsState()
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt
index 2b7d61bc..e8dc7d1d 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt
@@ -1,7 +1,12 @@
package com.github.damontecres.wholphin.ui.playback
+import androidx.core.net.toUri
+import androidx.media3.common.MediaMetadata
+import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Chapter
+import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.TrickplayInfo
+import org.jellyfin.sdk.model.extensions.ticks
data class CurrentMediaInfo(
val sourceId: String?,
@@ -15,3 +20,22 @@ data class CurrentMediaInfo(
val EMPTY = CurrentMediaInfo(null, null, listOf(), listOf(), listOf(), null)
}
}
+
+fun BaseItem.toMediaMetadata(imageUrl: String?): MediaMetadata =
+ MediaMetadata
+ .Builder()
+ .setTitle(title)
+ .setSubtitle(subtitle)
+ .setReleaseYear(data.productionYear)
+ .setDescription(data.overview)
+ .setArtworkUri(imageUrl?.toUri())
+ .setDurationMs(data.runTimeTicks?.ticks?.inWholeMilliseconds)
+ .setMediaType(
+ when (type) {
+ BaseItemKind.MOVIE -> MediaMetadata.MEDIA_TYPE_MOVIE
+ BaseItemKind.EPISODE -> MediaMetadata.MEDIA_TYPE_TV_SHOW
+ BaseItemKind.VIDEO -> MediaMetadata.MEDIA_TYPE_VIDEO
+ BaseItemKind.TV_CHANNEL, BaseItemKind.CHANNEL, BaseItemKind.LIVE_TV_CHANNEL -> MediaMetadata.MEDIA_TYPE_TV_CHANNEL
+ else -> MediaMetadata.MEDIA_TYPE_VIDEO
+ },
+ ).build()
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt
index b3813ed5..e1a291e1 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt
@@ -102,7 +102,7 @@ fun DownloadSubtitlesContent(
.padding(PaddingValues(24.dp)),
) {
Text(
- text = "Search & download subtitles",
+ text = stringResource(R.string.search_and_download_subtitles),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
@@ -113,7 +113,7 @@ fun DownloadSubtitlesContent(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
- text = "Language",
+ text = stringResource(R.string.language),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
)
@@ -137,7 +137,7 @@ fun DownloadSubtitlesContent(
}
if (dialogItems.isEmpty()) {
Text(
- text = "No remote subtitles were found",
+ text = stringResource(R.string.no_subtitles_found),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt
index 55fd5424..474c7932 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt
@@ -1,20 +1,22 @@
package com.github.damontecres.wholphin.ui.playback
import androidx.compose.ui.layout.ContentScale
+import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.preferences.PrefContentScale
import org.jellyfin.sdk.model.api.BaseItemKind
+import org.jellyfin.sdk.model.api.CollectionType
val playbackSpeedOptions = listOf(".25", ".5", ".75", "1.0", "1.25", "1.5", "1.75", "2.0")
val playbackScaleOptions =
mapOf(
- ContentScale.Fit to "Fit",
- ContentScale.None to "None",
- ContentScale.Crop to "Crop",
+ ContentScale.Fit to R.string.content_scale_fit,
+ ContentScale.None to R.string.none,
+ ContentScale.Crop to R.string.content_scale_crop,
// ContentScale.Inside to "Inside",
- ContentScale.FillBounds to "Fill",
- ContentScale.FillWidth to "Fill Width",
- ContentScale.FillHeight to "Fill Height",
+ ContentScale.FillBounds to R.string.content_scale_fill,
+ ContentScale.FillWidth to R.string.content_scale_fill_width,
+ ContentScale.FillHeight to R.string.content_scale_fill_height,
)
val PrefContentScale.scale: ContentScale
@@ -81,3 +83,20 @@ val BaseItemKind.playable: Boolean
BaseItemKind.YEAR,
-> false
}
+
+fun getTypeFor(collectionType: CollectionType): BaseItemKind? =
+ when (collectionType) {
+ CollectionType.UNKNOWN -> null
+ CollectionType.MOVIES -> BaseItemKind.MOVIE
+ CollectionType.TVSHOWS -> BaseItemKind.SERIES
+ CollectionType.MUSIC -> BaseItemKind.AUDIO
+ CollectionType.MUSICVIDEOS -> BaseItemKind.MUSIC_VIDEO
+ CollectionType.TRAILERS -> BaseItemKind.TRAILER
+ CollectionType.HOMEVIDEOS -> BaseItemKind.VIDEO
+ CollectionType.BOXSETS -> BaseItemKind.BOX_SET
+ CollectionType.BOOKS -> BaseItemKind.BOOK
+ CollectionType.PHOTOS -> BaseItemKind.PHOTO_ALBUM
+ CollectionType.LIVETV -> BaseItemKind.LIVE_TV_CHANNEL
+ CollectionType.PLAYLISTS -> BaseItemKind.PLAYLIST
+ CollectionType.FOLDERS -> BaseItemKind.FOLDER
+ }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt
index 04ff620a..c6d7b622 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt
@@ -144,7 +144,9 @@ fun PlaybackDialog(
BottomDialogItem(
data = PlaybackDialogType.VIDEO_SCALE,
headline = stringResource(R.string.video_scale),
- supporting = playbackScaleOptions[settings.contentScale],
+ supporting =
+ playbackScaleOptions[settings.contentScale]
+ ?.let { stringResource(it) },
),
)
}
@@ -220,7 +222,7 @@ fun PlaybackDialog(
playbackScaleOptions.map { (scale, name) ->
BottomDialogItem(
data = scale,
- headline = name,
+ headline = stringResource(name),
supporting = null,
)
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt
index ac6aaeed..b1500b60 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt
@@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.ui.playback
import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
@@ -18,10 +19,15 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.relocation.BringIntoViewRequester
+import androidx.compose.foundation.relocation.bringIntoViewRequester
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -32,6 +38,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
@@ -251,8 +258,34 @@ fun PlaybackOverlay(
exit = slideOutVertically { it / 2 } + fadeOut(),
) {
if (chapters.isNotEmpty()) {
+ val bringIntoViewRequester = remember { BringIntoViewRequester() }
+ val chapterIndex =
+ remember {
+ val position = playerControls.currentPosition.milliseconds
+ val index =
+ chapters
+ .indexOfFirst { it.position > position }
+ .minus(1)
+ .let {
+ if (it < 0) {
+ // Didn't find a chapter, so it's either the first or last
+ if (position < chapters.first().position) {
+ 0
+ } else {
+ chapters.lastIndex
+ }
+ } else {
+ it
+ }
+ }.coerceIn(0, chapters.lastIndex)
+ index
+ }
+ val listState = rememberLazyListState(chapterIndex)
val focusRequester = remember { FocusRequester() }
- LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
+ LaunchedEffect(Unit) {
+ bringIntoViewRequester.bringIntoView()
+ focusRequester.tryRequestFocus()
+ }
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
@@ -273,6 +306,7 @@ fun PlaybackOverlay(
style = MaterialTheme.typography.titleLarge,
)
LazyRow(
+ state = listState,
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier =
@@ -302,10 +336,19 @@ fun PlaybackOverlay(
},
interactionSource = interactionSource,
modifier =
- Modifier.ifElse(
- index == 0,
- Modifier.focusRequester(focusRequester),
- ),
+ Modifier
+ .ifElse(
+ index == chapterIndex,
+ Modifier
+ .focusRequester(focusRequester)
+ .bringIntoViewRequester(bringIntoViewRequester),
+ ).ifElse(
+ index == 0,
+ Modifier.focusProperties {
+ // Prevent scrolling left on first card to prevent moving down
+ left = FocusRequester.Cancel
+ },
+ ),
)
}
}
@@ -443,6 +486,12 @@ fun PlaybackOverlay(
text = (seekProgressMs / 1000L).seconds.toString(),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.labelLarge,
+ modifier =
+ Modifier
+ .background(
+ Color.Black.copy(alpha = 0.6f),
+ shape = RoundedCornerShape(4.dp),
+ ).padding(horizontal = 8.dp, vertical = 4.dp),
)
}
}
@@ -525,13 +574,22 @@ fun Controller(
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
onNextStateFocus: () -> Unit = {},
) {
+ val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState()
+ val verticalOffset by animateDpAsState(
+ targetValue = if (seekBarFocused) (-32).dp else 0.dp,
+ label = "TitleBumpOffset",
+ )
+
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
- modifier = Modifier.padding(start = 16.dp),
+ modifier =
+ Modifier
+ .padding(start = 16.dp)
+ .offset(y = verticalOffset),
) {
title?.let {
Text(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt
index f8b80020..0313a8c5 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt
@@ -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),
)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt
index 931ac551..4d44ff80 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt
@@ -41,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.EMPTY)
val currentPlayback = MutableStateFlow(null)
val currentItemPlayback = MutableLiveData()
- val currentSegment = EqualityMutableLiveData(null)
- private val autoSkippedSegments = mutableSetOf()
+ val currentSegment = MutableStateFlow(null)
val subtitleCues = MutableLiveData>(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()
+ private val outroShownSegments = mutableSetOf()
/**
* Cancels listening for segments and clears current segment state
*/
- private suspend fun resetSegmentState() {
+ private fun resetSegmentState() {
segmentJob?.cancel()
autoSkippedSegments.clear()
- currentSegment.setValueOnMain(null)
+ outroShownSegments.clear()
+ currentSegment.value = null
}
/**
@@ -988,20 +1002,27 @@ class PlaybackViewModel
it.type != MediaSegmentType.UNKNOWN && currentTicks >= it.startTicks && currentTicks < it.endTicks
}
if (currentSegment != null &&
- currentSegment.itemId == this@PlaybackViewModel.itemId &&
- autoSkippedSegments.add(currentSegment.id)
+ currentSegment.itemId == this@PlaybackViewModel.itemId
) {
- Timber.d(
- "Found media segment for %s: %s, %s",
- currentSegment.itemId,
- currentSegment.id,
- currentSegment.type,
- )
+ if (currentSegment.id !=
+ this@PlaybackViewModel
+ .currentSegment.value
+ ?.segment
+ ?.id
+ ) {
+ Timber.d(
+ "Found media segment for %s: %s, %s",
+ currentSegment.itemId,
+ currentSegment.id,
+ currentSegment.type,
+ )
+ }
val playlist = this@PlaybackViewModel.playlist.value
if (currentSegment.type == MediaSegmentType.OUTRO &&
prefs.showNextUpWhen == ShowNextUpWhen.DURING_CREDITS &&
- playlist != null && playlist.hasNext()
+ playlist != null && playlist.hasNext() &&
+ outroShownSegments.add(currentSegment.id)
) {
val nextItem = playlist.peek()
Timber.v("Setting next up during outro to ${nextItem?.id}")
@@ -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,
+)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt
new file mode 100644
index 00000000..afdc93a1
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt
@@ -0,0 +1,365 @@
+package com.github.damontecres.wholphin.ui.preferences
+
+import android.content.Context
+import androidx.annotation.StringRes
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.wrapContentWidth
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.datastore.core.DataStore
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.tv.material3.ListItem
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Switch
+import androidx.tv.material3.Text
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.data.ServerPreferencesDao
+import com.github.damontecres.wholphin.data.ServerRepository
+import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
+import com.github.damontecres.wholphin.data.model.NavPinType
+import com.github.damontecres.wholphin.preferences.AppPreferences
+import com.github.damontecres.wholphin.services.BackdropService
+import com.github.damontecres.wholphin.services.NavDrawerItemState
+import com.github.damontecres.wholphin.services.NavDrawerService
+import com.github.damontecres.wholphin.services.NavigationManager
+import com.github.damontecres.wholphin.services.SeerrServerRepository
+import com.github.damontecres.wholphin.ui.FontAwesome
+import com.github.damontecres.wholphin.ui.PreviewTvSpec
+import com.github.damontecres.wholphin.ui.components.BasicDialog
+import com.github.damontecres.wholphin.ui.components.Button
+import com.github.damontecres.wholphin.ui.launchDefault
+import com.github.damontecres.wholphin.ui.launchIO
+import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
+import com.github.damontecres.wholphin.ui.theme.WholphinTheme
+import com.github.damontecres.wholphin.util.ExceptionHandler
+import com.github.damontecres.wholphin.util.RememberTabManager
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.withContext
+import org.jellyfin.sdk.api.client.ApiClient
+import javax.inject.Inject
+
+data class NavDrawerPin(
+ val id: String,
+ val title: String,
+ val pinned: Boolean,
+ val item: NavDrawerItem,
+) {
+ companion object {
+ fun create(
+ context: Context,
+ items: Map,
+ ) {
+ items.map { (item, pinned) ->
+ NavDrawerPin(item.id, item.name(context), pinned, item)
+ }
+ }
+ }
+}
+
+enum class MoveDirection {
+ UP,
+ DOWN,
+}
+
+private fun List.move(
+ direction: MoveDirection,
+ index: Int,
+): List =
+ toMutableList().apply {
+ if (direction == MoveDirection.DOWN) {
+ val down = this[index]
+ val up = this[index + 1]
+ set(index, up)
+ set(index + 1, down)
+ } else {
+ val up = this[index]
+ val down = this[index - 1]
+ set(index - 1, up)
+ set(index, down)
+ }
+ }
+
+@Composable
+fun NavDrawerPreference(
+ title: String,
+ summary: String?,
+ modifier: Modifier = Modifier,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+ viewModel: NavDrawerPreferencesViewModel = hiltViewModel(),
+) {
+ val items by viewModel.state.collectAsState()
+ var showDialog by remember { mutableStateOf(false) }
+ ClickPreference(
+ title = title,
+ summary = summary,
+ onClick = { showDialog = true },
+ interactionSource = interactionSource,
+ modifier = modifier,
+ )
+ if (showDialog) {
+ NavDrawerPreferenceDialog(
+ items = items,
+ onDismissRequest = {
+ viewModel.save()
+ showDialog = false
+ },
+ onClick = { index ->
+ val newItems =
+ items.toMutableList().apply {
+ set(index, items[index].let { it.copy(pinned = !it.pinned) })
+ }
+ viewModel.update(newItems)
+ },
+ onMoveUp = { index ->
+ viewModel.update(items.move(MoveDirection.UP, index))
+ },
+ onMoveDown = { index ->
+ viewModel.update(items.move(MoveDirection.DOWN, index))
+ },
+ )
+ }
+}
+
+@Composable
+fun NavDrawerPreferenceDialog(
+ items: List,
+ onDismissRequest: () -> Unit,
+ onClick: (Int) -> Unit,
+ onMoveUp: (Int) -> Unit,
+ onMoveDown: (Int) -> Unit,
+) {
+ BasicDialog(
+ onDismissRequest = onDismissRequest,
+ elevation = 3.dp,
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .padding(16.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.nav_drawer_pins),
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.padding(bottom = 8.dp),
+ )
+ val listState = rememberLazyListState()
+ LazyColumn(
+ state = listState,
+ verticalArrangement = Arrangement.spacedBy(0.dp),
+ ) {
+ itemsIndexed(items, key = { _, item -> item.id }) { index, item ->
+ NavDrawerPreferenceListItem(
+ title = item.title,
+ pinned = item.pinned,
+ moveUpAllowed = index > 0,
+ moveDownAllowed = index < items.lastIndex,
+ onClick = { onClick.invoke(index) },
+ onMoveUp = { onMoveUp.invoke(index) },
+ onMoveDown = { onMoveDown.invoke(index) },
+ modifier = Modifier.animateItem(),
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun NavDrawerPreferenceListItem(
+ title: String,
+ pinned: Boolean,
+ moveUpAllowed: Boolean,
+ moveDownAllowed: Boolean,
+ onClick: () -> Unit,
+ onMoveUp: () -> Unit,
+ onMoveDown: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier = modifier,
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .heightIn(min = 40.dp, max = 88.dp),
+ ) {
+ ListItem(
+ selected = false,
+ headlineContent = {
+ Text(
+ text = title,
+ )
+ },
+ trailingContent = {
+ Switch(
+ checked = pinned,
+ onCheckedChange = {
+ onClick.invoke()
+ },
+ )
+ },
+ onClick = onClick,
+ modifier = Modifier.weight(1f),
+ )
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ modifier = Modifier.wrapContentWidth(),
+ ) {
+ MoveButton(R.string.fa_caret_up, moveUpAllowed, onMoveUp)
+ MoveButton(R.string.fa_caret_down, moveDownAllowed, onMoveDown)
+ }
+ }
+ }
+}
+
+@Composable
+private fun MoveButton(
+ @StringRes icon: Int,
+ enabled: Boolean,
+ onClick: () -> Unit,
+) = Button(
+ onClick = onClick,
+ enabled = enabled,
+ modifier = Modifier.size(32.dp),
+) {
+ Text(
+ text = stringResource(icon),
+ fontSize = 16.sp,
+ fontFamily = FontAwesome,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(),
+ )
+}
+
+@PreviewTvSpec
+@Composable
+fun NavDrawerPreferenceListItemPreview() {
+ WholphinTheme {
+ NavDrawerPreferenceListItem(
+ title = "Movies",
+ pinned = true,
+ moveUpAllowed = true,
+ moveDownAllowed = true,
+ onClick = {},
+ onMoveUp = {},
+ onMoveDown = { },
+ modifier = Modifier.width(360.dp),
+ )
+ }
+}
+
+@HiltViewModel
+class NavDrawerPreferencesViewModel
+ @Inject
+ constructor(
+ @param:ApplicationContext private val context: Context,
+ private val api: ApiClient,
+ val preferenceDataStore: DataStore,
+ val navigationManager: NavigationManager,
+ val backdropService: BackdropService,
+ private val rememberTabManager: RememberTabManager,
+ private val serverRepository: ServerRepository,
+ private val navDrawerService: NavDrawerService,
+ private val serverPreferencesDao: ServerPreferencesDao,
+ private val seerrServerRepository: SeerrServerRepository,
+ ) : ViewModel() {
+ val state = MutableStateFlow>(listOf())
+
+ init {
+ viewModelScope.launchDefault {
+ val state = navDrawerService.state.value
+ val user = serverRepository.currentUser.value
+ val seerr = seerrServerRepository.active.firstOrNull()
+ if (state == NavDrawerItemState.EMPTY || user == null || seerr == null) {
+ return@launchDefault
+ }
+ val navDrawerPins =
+ withContext(Dispatchers.IO) {
+ serverPreferencesDao
+ .getNavDrawerPinnedItems(user)
+ .associateBy { it.itemId }
+ }
+ val allItems = state.let { it.items + it.moreItems }
+ val pins =
+ allItems
+ .sortedBy {
+ navDrawerPins[it.id]?.order?.takeIf { it >= 0 } ?: Int.MAX_VALUE
+ }.mapNotNull {
+ if (!seerr && it is NavDrawerItem.Discover) {
+ null
+ } else {
+ // Assume pinned if unknown
+ val pinned = navDrawerPins[it.id]?.type ?: NavPinType.PINNED
+ NavDrawerPin(
+ it.id,
+ it.name(context),
+ pinned == NavPinType.PINNED,
+ it,
+ )
+ }
+ }
+ this@NavDrawerPreferencesViewModel.state.value = pins
+ }
+ }
+
+ fun update(items: List) {
+ state.update { items }
+ }
+
+ fun save() {
+ viewModelScope.launchIO(ExceptionHandler(true)) {
+ serverRepository.currentUser.value?.let { user ->
+ serverRepository.currentUserDto.value?.let { userDto ->
+ if (user.id == userDto.id) {
+ val toSave =
+ state.value.mapIndexed { index, item ->
+ NavDrawerPinnedItem(
+ user.rowId,
+ item.id,
+ if (item.pinned) NavPinType.PINNED else NavPinType.UNPINNED,
+ index,
+ )
+ }
+ serverPreferencesDao.saveNavDrawerPinnedItems(*toSave.toTypedArray())
+ navDrawerService.updateNavDrawer(user, userDto)
+ } else {
+ throw IllegalStateException("User IDs do not match")
+ }
+ }
+ }
+ }
+ }
+ }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt
index dcbda2fc..b04293f8 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt
@@ -11,8 +11,10 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
@@ -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.None) }
+ var showQuickConnectDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
viewModel.preferenceDataStore.data.collect {
@@ -165,291 +168,310 @@ fun PreferencesContent(
LaunchedEffect(Unit) {
focusRequester.tryRequestFocus()
}
- LazyColumn(
- state = state,
- horizontalAlignment = Alignment.Start,
- verticalArrangement = Arrangement.spacedBy(0.dp),
- contentPadding = PaddingValues(16.dp),
+ Column(
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)),
) {
- stickyHeader {
- Text(
- text = stringResource(screenTitle),
- style = MaterialTheme.typography.headlineSmall,
- color = MaterialTheme.colorScheme.onSurface,
- textAlign = TextAlign.Center,
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(vertical = 8.dp),
- )
- }
- if (UpdateChecker.ACTIVE &&
- preferenceScreenOption == PreferenceScreenOption.BASIC &&
- preferences.autoCheckForUpdates &&
- updateAvailable
+ Text(
+ text = stringResource(screenTitle),
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp),
+ )
+ LazyColumn(
+ state = state,
+ horizontalAlignment = Alignment.Start,
+ verticalArrangement = Arrangement.spacedBy(0.dp),
+ contentPadding = PaddingValues(16.dp),
+ modifier = Modifier.fillMaxSize(),
) {
- item {
- val updateFocusRequester = remember { FocusRequester() }
- LaunchedEffect(Unit) {
- if (focusedIndex.first == 0 && focusedIndex.second == 0) {
- // Only re-focus if the user hasn't moved
- updateFocusRequester.tryRequestFocus()
- }
- }
- ClickPreference(
- title = stringResource(R.string.install_update),
- onClick = {
- if (movementSounds) playOnClickSound(context)
- viewModel.navigationManager.navigateTo(Destination.UpdateApp)
- },
- summary = release?.version?.toString(),
- modifier =
- Modifier
- .focusRequester(updateFocusRequester)
- .playSoundOnFocus(movementSounds),
- )
- }
- }
- prefList.forEachIndexed { groupIndex, group ->
- item {
- Text(
- text = stringResource(group.title),
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.onSurface,
- textAlign = TextAlign.Start,
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(top = 8.dp, bottom = 4.dp),
- )
- }
- val groupPreferences =
- group.preferences +
- group.conditionalPreferences
- .filter { it.condition.invoke(preferences) }
- .map { it.preferences }
- .flatten()
- groupPreferences.forEachIndexed { prefIndex, pref ->
- pref as AppPreference
+ if (UpdateChecker.ACTIVE &&
+ preferenceScreenOption == PreferenceScreenOption.BASIC &&
+ preferences.autoCheckForUpdates &&
+ updateAvailable
+ ) {
item {
- val interactionSource = remember { MutableInteractionSource() }
- val focused = interactionSource.collectIsFocusedAsState().value
- LaunchedEffect(focused) {
- if (focused) {
- focusedIndex = Pair(groupIndex, prefIndex)
- if (movementSounds) playOnClickSound(context)
- onFocus.invoke(groupIndex, prefIndex)
+ val updateFocusRequester = remember { FocusRequester() }
+ LaunchedEffect(Unit) {
+ if (focusedIndex.first == 0 && focusedIndex.second == 0) {
+ // Only re-focus if the user hasn't moved
+ updateFocusRequester.tryRequestFocus()
}
}
- when (pref) {
- AppPreference.InstalledVersion -> {
- var clickCount by remember { mutableIntStateOf(0) }
- ClickPreference(
- title = stringResource(R.string.installed_version),
- onClick = {
- if (movementSounds) playOnClickSound(context)
- if (clickCount++ >= 2) {
- clickCount = 0
- viewModel.navigationManager.navigateTo(Destination.Debug)
- }
- },
- summary = installedVersion.toString(),
- interactionSource = interactionSource,
- modifier =
- Modifier
- .ifElse(
- groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
- Modifier.focusRequester(focusRequester),
- ),
- )
- }
-
- AppPreference.Update -> {
- ClickPreference(
- title =
- if (release != null && updateAvailable) {
- stringResource(R.string.install_update)
- } else if (!preferences.autoCheckForUpdates && release == null) {
- stringResource(R.string.check_for_updates)
- } else {
- stringResource(R.string.no_update_available)
- },
- onClick = {
- if (movementSounds) playOnClickSound(context)
- if (release != null && updateAvailable) {
- release?.let {
- viewModel.navigationManager.navigateTo(Destination.UpdateApp)
- }
- } else {
- updateVM.init(preferences.updateUrl)
- }
- },
- onLongClick = {
- if (movementSounds) playOnClickSound(context)
- viewModel.navigationManager.navigateTo(Destination.UpdateApp)
- },
- summary =
- if (updateAvailable) {
- release?.version?.toString()
- } else {
- null
- },
- interactionSource = interactionSource,
- modifier =
- Modifier
- .ifElse(
- groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
- Modifier.focusRequester(focusRequester),
- ),
- )
- }
-
- AppPreference.ClearImageCache -> {
- val summary =
- remember(cacheUsage) {
- cacheUsage.let {
- val diskMB = it.imageDiskUsed / AppPreference.MEGA_BIT
- val memoryUsedMB =
- it.imageMemoryUsed / AppPreference.MEGA_BIT
- val memoryMaxMB =
- it.imageMemoryMax / AppPreference.MEGA_BIT
- "Disk: ${diskMB}mb, Memory: ${memoryUsedMB}mb/${memoryMaxMB}mb"
- }
- }
- ClickPreference(
- title = stringResource(pref.title),
- onClick = {
- SingletonImageLoader.get(context).let {
- it.memoryCache?.clear()
- it.diskCache?.clear()
- updateCache = true
- }
- },
- modifier = Modifier,
- summary = summary,
- onLongClick = {},
- interactionSource = interactionSource,
- )
- }
-
- AppPreference.UserPinnedNavDrawerItems -> {
- val selectedItems =
- navDrawerPins.keys.mapNotNull {
- if (navDrawerPins[it] ?: false) it else null
- }
- MultiChoicePreference(
- title = stringResource(pref.title),
- summary = pref.summary(context, null),
- possibleValues = navDrawerPins.keys,
- selectedValues = selectedItems.toSet(),
- onValueChange = { newSelectedItems ->
- viewModel.updatePins(newSelectedItems)
- },
- ) {
- Text(it.name(context))
+ ClickPreference(
+ title = stringResource(R.string.install_update),
+ onClick = {
+ if (movementSounds) playOnClickSound(context)
+ viewModel.navigationManager.navigateTo(Destination.UpdateApp)
+ },
+ summary = release?.version?.toString(),
+ modifier =
+ Modifier
+ .focusRequester(updateFocusRequester)
+ .playSoundOnFocus(movementSounds),
+ )
+ }
+ }
+ prefList.forEachIndexed { groupIndex, group ->
+ item {
+ Text(
+ text = stringResource(group.title),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Start,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp, bottom = 4.dp),
+ )
+ }
+ val groupPreferences =
+ group.preferences +
+ group.conditionalPreferences
+ .filter { it.condition.invoke(preferences) }
+ .map { it.preferences }
+ .flatten()
+ groupPreferences.forEachIndexed { prefIndex, pref ->
+ pref as AppPreference
+ item {
+ val interactionSource = remember { MutableInteractionSource() }
+ val focused = interactionSource.collectIsFocusedAsState().value
+ LaunchedEffect(focused) {
+ if (focused) {
+ focusedIndex = Pair(groupIndex, prefIndex)
+ if (movementSounds) playOnClickSound(context)
+ onFocus.invoke(groupIndex, prefIndex)
}
}
-
- AppPreference.SendAppLogs -> {
- ClickPreference(
- title = stringResource(pref.title),
- onClick = {
- viewModel.sendAppLogs()
- },
- modifier = Modifier,
- summary = pref.summary(context, null),
- onLongClick = {},
- interactionSource = interactionSource,
- )
- }
-
- SubtitleSettings.Reset -> {
- ClickPreference(
- title = stringResource(pref.title),
- onClick = {
- viewModel.resetSubtitleSettings()
- },
- modifier = Modifier,
- summary = pref.summary(context, null),
- onLongClick = {},
- interactionSource = interactionSource,
- )
- }
-
- AppPreference.RequireProfilePin -> {
- SwitchPreference(
- title = stringResource(pref.title),
- value = currentUser?.pin.isNotNullOrBlank(),
- onClick = {
- showPinFlow = true
- },
- summaryOn = stringResource(R.string.enabled),
- summaryOff = null,
- modifier = Modifier,
- )
- }
-
- AppPreference.SeerrIntegration -> {
- ClickPreference(
- title = stringResource(pref.title),
- onClick = {
- if (seerrIntegrationEnabled) {
- seerrDialogMode = SeerrDialogMode.Remove
- } else {
- seerrVm.resetStatus()
- seerrDialogMode = SeerrDialogMode.Add
- }
- },
- modifier = Modifier,
- summary =
- if (seerrIntegrationEnabled) {
- stringResource(R.string.enabled)
- } else {
- null
- },
- onLongClick = {},
- interactionSource = interactionSource,
- )
- }
-
- else -> {
- val value = pref.getter.invoke(preferences)
- ComposablePreference(
- preference = pref,
- value = value,
- onNavigate = viewModel.navigationManager::navigateTo,
- onValueChange = { newValue ->
- val validation = pref.validate(newValue)
- when (validation) {
- is PreferenceValidation.Invalid -> {
- // TODO?
- Toast
- .makeText(
- context,
- validation.message,
- Toast.LENGTH_SHORT,
- ).show()
+ when (pref) {
+ AppPreference.InstalledVersion -> {
+ var clickCount by remember { mutableIntStateOf(0) }
+ ClickPreference(
+ title = stringResource(R.string.installed_version),
+ onClick = {
+ if (movementSounds) playOnClickSound(context)
+ if (clickCount++ >= 2) {
+ clickCount = 0
+ viewModel.navigationManager.navigateTo(Destination.Debug)
}
+ },
+ summary = installedVersion.toString(),
+ interactionSource = interactionSource,
+ modifier =
+ Modifier
+ .ifElse(
+ groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
+ Modifier.focusRequester(focusRequester),
+ ),
+ )
+ }
- PreferenceValidation.Valid -> {
- scope.launch(ExceptionHandler()) {
- preferences =
- viewModel.preferenceDataStore.updateData { prefs ->
- pref.setter(prefs, newValue)
- }
+ AppPreference.Update -> {
+ ClickPreference(
+ title =
+ if (release != null && updateAvailable) {
+ stringResource(R.string.install_update)
+ } else if (!preferences.autoCheckForUpdates && release == null) {
+ stringResource(R.string.check_for_updates)
+ } else {
+ stringResource(R.string.no_update_available)
+ },
+ onClick = {
+ if (movementSounds) playOnClickSound(context)
+ if (release != null && updateAvailable) {
+ release?.let {
+ viewModel.navigationManager.navigateTo(Destination.UpdateApp)
+ }
+ } else {
+ updateVM.init(preferences.updateUrl)
+ }
+ },
+ onLongClick = {
+ if (movementSounds) playOnClickSound(context)
+ viewModel.navigationManager.navigateTo(Destination.UpdateApp)
+ },
+ summary =
+ if (updateAvailable) {
+ release?.version?.toString()
+ } else {
+ null
+ },
+ interactionSource = interactionSource,
+ modifier =
+ Modifier
+ .ifElse(
+ groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
+ Modifier.focusRequester(focusRequester),
+ ),
+ )
+ }
+
+ AppPreference.ClearImageCache -> {
+ val summary =
+ remember(cacheUsage) {
+ cacheUsage.let {
+ val diskMB = it.imageDiskUsed / AppPreference.MEGA_BIT
+ val memoryUsedMB =
+ it.imageMemoryUsed / AppPreference.MEGA_BIT
+ val memoryMaxMB =
+ it.imageMemoryMax / AppPreference.MEGA_BIT
+ "Disk: ${diskMB}mb, Memory: ${memoryUsedMB}mb/${memoryMaxMB}mb"
+ }
+ }
+ ClickPreference(
+ title = stringResource(pref.title),
+ onClick = {
+ SingletonImageLoader.get(context).let {
+ it.memoryCache?.clear()
+ it.diskCache?.clear()
+ updateCache = true
+ }
+ },
+ modifier = Modifier,
+ summary = summary,
+ onLongClick = {},
+ interactionSource = interactionSource,
+ )
+ }
+
+ AppPreference.UserPinnedNavDrawerItems -> {
+ NavDrawerPreference(
+ title = stringResource(pref.title),
+ summary = pref.summary(context, null),
+ modifier = Modifier,
+ interactionSource = interactionSource,
+ )
+ }
+
+ AppPreference.SendAppLogs -> {
+ ClickPreference(
+ title = stringResource(pref.title),
+ onClick = {
+ viewModel.sendAppLogs()
+ },
+ modifier = Modifier,
+ summary = pref.summary(context, null),
+ onLongClick = {},
+ interactionSource = interactionSource,
+ )
+ }
+
+ SubtitleSettings.Reset -> {
+ ClickPreference(
+ title = stringResource(pref.title),
+ onClick = {
+ viewModel.resetSubtitleSettings()
+ },
+ modifier = Modifier,
+ summary = pref.summary(context, null),
+ onLongClick = {},
+ interactionSource = interactionSource,
+ )
+ }
+
+ AppPreference.RequireProfilePin -> {
+ SwitchPreference(
+ title = stringResource(pref.title),
+ value = currentUser?.pin.isNotNullOrBlank(),
+ onClick = {
+ showPinFlow = true
+ },
+ summaryOn = stringResource(R.string.enabled),
+ summaryOff = null,
+ modifier = Modifier,
+ )
+ }
+
+ AppPreference.SeerrIntegration -> {
+ ClickPreference(
+ title = stringResource(pref.title),
+ onClick = {
+ seerrDialogMode =
+ when (val conn = seerrConnection) {
+ is SeerrConnectionStatus.Error -> {
+ SeerrDialogMode.Error(conn.serverUrl, conn.ex)
+ }
+
+ SeerrConnectionStatus.NotConfigured -> {
+ SeerrDialogMode.Add
+ }
+
+ is SeerrConnectionStatus.Success -> {
+ SeerrDialogMode.Remove(
+ conn.current.server.url,
+ )
+ }
+ }
+ },
+ modifier = Modifier,
+ summary =
+ when (seerrConnection) {
+ is SeerrConnectionStatus.Error -> stringResource(R.string.voice_error_server)
+ SeerrConnectionStatus.NotConfigured -> stringResource(R.string.add_server)
+ is SeerrConnectionStatus.Success -> stringResource(R.string.enabled)
+ },
+ onLongClick = {},
+ interactionSource = interactionSource,
+ )
+ }
+
+ AppPreference.QuickConnect -> {
+ ClickPreference(
+ title = stringResource(pref.title),
+ onClick = {
+ if (currentUser != null) {
+ viewModel.resetQuickConnectStatus()
+ showQuickConnectDialog = true
+ }
+ },
+ modifier = Modifier,
+ summary = pref.summary(context, null),
+ onLongClick = {},
+ interactionSource = interactionSource,
+ )
+ }
+
+ else -> {
+ val value = pref.getter.invoke(preferences)
+ ComposablePreference(
+ preference = pref,
+ value = value,
+ onNavigate = viewModel.navigationManager::navigateTo,
+ onValueChange = { newValue ->
+ val validation = pref.validate(newValue)
+ when (validation) {
+ is PreferenceValidation.Invalid -> {
+ // TODO?
+ Toast
+ .makeText(
+ context,
+ validation.message,
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+
+ PreferenceValidation.Valid -> {
+ scope.launch(ExceptionHandler()) {
+ preferences =
+ viewModel.preferenceDataStore.updateData { prefs ->
+ pref.setter(prefs, newValue)
+ }
+ }
}
}
- }
- },
- interactionSource = interactionSource,
- modifier =
- Modifier
- .ifElse(
- groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
- Modifier.focusRequester(focusRequester),
- ),
- )
+ },
+ interactionSource = interactionSource,
+ modifier =
+ Modifier
+ .ifElse(
+ groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
+ Modifier.focusRequester(focusRequester),
+ ),
+ )
+ }
}
}
}
@@ -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
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt
index 9973bbec..bec82e58 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt
@@ -2,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
- val navDrawerPins = MutableLiveData