diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 00000000..eae1ff9c
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,12 @@
+## Description
+
+
+### Related issues
+
+
+
+### Screenshots
+
+
+### AI/LLM usage
+
diff --git a/.gitignore b/.gitignore
index a5248a5b..695422b8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -53,7 +53,9 @@ app/libs/
ffmpeg_decoder/
app/release/
app/debug/
-<<<<<<< HEAD
+.vscode
+.kotlin
+docs/
# mpv
app/src/main/obj/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index f8ec6d53..75d48ceb 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -18,3 +18,8 @@ repos:
entry: val DEBUG = true
language: pygrep
files: '.+\.kt'
+ - id: check-debug-logging-enabled
+ name: Check debug enabled
+ entry: debugLogging = true
+ language: pygrep
+ files: '.+\.kt'
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index 1a723a9e..6856ebb2 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -23,9 +23,17 @@ We follow GitHub's fork & pull request model for contributions.
After forking and cloning your fork, you can import the project into Android Studio.
-You need a compatible Android Studio version for the configured AGP. This is generally `Narwhal 3 Feature Drop | 2025.1.3` or newer. See https://developer.android.com/build/releases/gradle-plugin and [`libs.versions.toml](./gradle/libs.versions.toml).
+### Development environment
-### Code organization
+It is recommended to use a recent version of [Android Studio](https://developer.android.com/studio). Make sure the [version is compatible](https://developer.android.com/build/releases/gradle-plugin#android_gradle_plugin_and_android_studio_compatibility) with Wholphin's AGP version.
+
+Code formatting should follow [ktlint's](https://github.com/pinterest/ktlint) rules. Find the `ktlint` version in [`.pre-commit-config.yaml`](./.pre-commit-config.yaml). Optionally, install the [ktlint plugin](https://plugins.jetbrains.com/plugin/15057-ktlint) in Android Studio to run automatically. Configure the version in `Settings->Tools->KtLint->Ruleset Version`.
+
+Also, it's recommend to add an extra ruleset jar for Compose-specific KtLint: https://mrmans0n.github.io/compose-rules/ktlint/#using-with-ktlint-cli-or-the-ktlint-intellij-plugin
+
+Also setup [pre-commit](https://github.com/pre-commit/pre-commit) which will run `ktlint` as well on each commit, plus check for other common issues.
+
+## Code organization
Code is split into several packages:
- `data` - app-specific data models and services
diff --git a/README.md b/README.md
index fd07c32d..45c96f5a 100644
--- a/README.md
+++ b/README.md
@@ -7,15 +7,21 @@ Wholphin is an open-source Android TV client for Jellyfin. It aims to provide a
This is not a fork of the [official client](https://github.com/jellyfin/jellyfin-androidtv). Wholphin's user interface and controls have been written completely from scratch. Wholphin `v0.3.0+` supports playing media using either ExoPlayer/Media3 or MPV (experimental).
-Help get Wholphin listed on the Play Store
-
-
+
+
+
+
+
+
+
+
+
@@ -24,12 +30,12 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin
### User interface
-- A navigation drawer for quick access to libraries, search, and settings from almost anywhere in the app
+- A navigation drawer for quick access to libraries, favorites, search, and settings from almost anywhere in the app
- Option to combine Continue Watching & Next Up rows
- Show Movie/TV Show titles when browsing libraries
- Play theme music, if available
+- Search & download subtitles (requires compatible server plugin such as [OpenSubtitles](https://github.com/jellyfin/jellyfin-plugin-opensubtitles))
- Customize layout grids for libraries
-- Access all your favorites quickly from the nav drawer
- Multiple app color themes
### Playback
@@ -43,6 +49,8 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin
- Optionally skip back a few seconds when resuming playback
- Live TV & DVR support
- Auto play next episodes with pass out protection
+- Option for automatic refresh rate switching on supported displays
+- Trickplay support
- Other (subjective) enhancements:
- Subtly show playback position along the bottom of the screen while seeking w/ D-Pad
- Force Continue Watching & Next Up TV episodes to use their Series posters
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index b258ecfb..b186a110 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -262,6 +262,7 @@ dependencies {
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.preference.ktx)
implementation(libs.androidx.room.testing)
+ implementation(libs.androidx.palette.ktx)
ksp(libs.androidx.room.compiler)
ksp(libs.hilt.android.compiler)
diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/11.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/11.json
new file mode 100644
index 00000000..bb9e1bd6
--- /dev/null
+++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/11.json
@@ -0,0 +1,383 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 11,
+ "identityHash": "26bbe361923e3aac064c08c52e65264e",
+ "entities": [
+ {
+ "tableName": "servers",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "users",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "rowId",
+ "columnName": "rowId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accessToken",
+ "columnName": "accessToken",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "pin",
+ "columnName": "pin",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "rowId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_users_id_serverId",
+ "unique": true,
+ "columnNames": [
+ "id",
+ "serverId"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)"
+ },
+ {
+ "name": "index_users_id",
+ "unique": false,
+ "columnNames": [
+ "id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)"
+ },
+ {
+ "name": "index_users_serverId",
+ "unique": false,
+ "columnNames": [
+ "serverId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "servers",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "serverId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "ItemPlayback",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "rowId",
+ "columnName": "rowId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "itemId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sourceId",
+ "columnName": "sourceId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "audioIndex",
+ "columnName": "audioIndex",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "subtitleIndex",
+ "columnName": "subtitleIndex",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "rowId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_ItemPlayback_userId_itemId",
+ "unique": true,
+ "columnNames": [
+ "userId",
+ "itemId"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "users",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "userId"
+ ],
+ "referencedColumns": [
+ "rowId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "NavDrawerPinnedItem",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "itemId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "userId",
+ "itemId"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "users",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "userId"
+ ],
+ "referencedColumns": [
+ "rowId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "LibraryDisplayInfo",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "itemId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sort",
+ "columnName": "sort",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "direction",
+ "columnName": "direction",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "filter",
+ "columnName": "filter",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'{}'"
+ },
+ {
+ "fieldPath": "viewOptions",
+ "columnName": "viewOptions",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "userId",
+ "itemId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_LibraryDisplayInfo_userId_itemId",
+ "unique": true,
+ "columnNames": [
+ "userId",
+ "itemId"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "users",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "userId"
+ ],
+ "referencedColumns": [
+ "rowId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "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"
+ ]
+ }
+ ]
+ }
+ ],
+ "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, '26bbe361923e3aac064c08c52e65264e')"
+ ]
+ }
+}
diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/12.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/12.json
new file mode 100644
index 00000000..f3b128c5
--- /dev/null
+++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/12.json
@@ -0,0 +1,434 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 12,
+ "identityHash": "e9bcae45448f995d1f4ebf291e45c510",
+ "entities": [
+ {
+ "tableName": "servers",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "users",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "rowId",
+ "columnName": "rowId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accessToken",
+ "columnName": "accessToken",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "pin",
+ "columnName": "pin",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "rowId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_users_id_serverId",
+ "unique": true,
+ "columnNames": [
+ "id",
+ "serverId"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)"
+ },
+ {
+ "name": "index_users_id",
+ "unique": false,
+ "columnNames": [
+ "id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)"
+ },
+ {
+ "name": "index_users_serverId",
+ "unique": false,
+ "columnNames": [
+ "serverId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "servers",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "serverId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "ItemPlayback",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "rowId",
+ "columnName": "rowId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "itemId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sourceId",
+ "columnName": "sourceId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "audioIndex",
+ "columnName": "audioIndex",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "subtitleIndex",
+ "columnName": "subtitleIndex",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "rowId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_ItemPlayback_userId_itemId",
+ "unique": true,
+ "columnNames": [
+ "userId",
+ "itemId"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "users",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "userId"
+ ],
+ "referencedColumns": [
+ "rowId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "NavDrawerPinnedItem",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "itemId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "userId",
+ "itemId"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "users",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "userId"
+ ],
+ "referencedColumns": [
+ "rowId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "LibraryDisplayInfo",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "itemId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sort",
+ "columnName": "sort",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "direction",
+ "columnName": "direction",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "filter",
+ "columnName": "filter",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'{}'"
+ },
+ {
+ "fieldPath": "viewOptions",
+ "columnName": "viewOptions",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "userId",
+ "itemId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_LibraryDisplayInfo_userId_itemId",
+ "unique": true,
+ "columnNames": [
+ "userId",
+ "itemId"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "users",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "userId"
+ ],
+ "referencedColumns": [
+ "rowId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "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"
+ ]
+ }
+ ]
+ }
+ ],
+ "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, 'e9bcae45448f995d1f4ebf291e45c510')"
+ ]
+ }
+}
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 ef90f38a..52a63020 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt
@@ -47,6 +47,7 @@ import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.ApplicationContent
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
+import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
import com.github.damontecres.wholphin.util.DebugLogTree
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
@@ -222,13 +223,15 @@ class MainActivity : AppCompatActivity() {
)
}
}
- ApplicationContent(
- user = current?.user,
- server = current?.server,
- navigationManager = navigationManager,
- preferences = preferences,
- modifier = Modifier.fillMaxSize(),
- )
+ ProvideLocalClock {
+ ApplicationContent(
+ user = current?.user,
+ server = current?.server,
+ navigationManager = navigationManager,
+ preferences = preferences,
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
}
}
}
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 5dd76067..fe6e7620 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
@@ -9,10 +9,12 @@ import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.data.model.ItemPlayback
+import com.github.damontecres.wholphin.data.model.ItemTrackModification
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
+import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
import com.github.damontecres.wholphin.ui.components.ViewOptions
import kotlinx.serialization.json.Json
import org.jellyfin.sdk.model.api.ItemSortBy
@@ -22,8 +24,16 @@ import timber.log.Timber
import java.util.UUID
@Database(
- entities = [JellyfinServer::class, JellyfinUser::class, ItemPlayback::class, NavDrawerPinnedItem::class, LibraryDisplayInfo::class],
- version = 10,
+ entities = [
+ JellyfinServer::class,
+ JellyfinUser::class,
+ ItemPlayback::class,
+ NavDrawerPinnedItem::class,
+ LibraryDisplayInfo::class,
+ PlaybackLanguageChoice::class,
+ ItemTrackModification::class,
+ ],
+ version = 12,
exportSchema = true,
autoMigrations = [
AutoMigration(3, 4),
@@ -33,6 +43,8 @@ import java.util.UUID
AutoMigration(7, 8),
AutoMigration(8, 9),
AutoMigration(9, 10),
+ AutoMigration(10, 11),
+ AutoMigration(11, 12),
],
)
@TypeConverters(Converters::class)
@@ -44,6 +56,8 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun serverPreferencesDao(): ServerPreferencesDao
abstract fun libraryDisplayInfoDao(): LibraryDisplayInfoDao
+
+ abstract fun playbackLanguageChoiceDao(): PlaybackLanguageChoiceDao
}
class Converters {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackDao.kt
index e0743040..dedbb8a7 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackDao.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackDao.kt
@@ -5,6 +5,7 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.github.damontecres.wholphin.data.model.ItemPlayback
+import com.github.damontecres.wholphin.data.model.ItemTrackModification
import com.github.damontecres.wholphin.data.model.JellyfinUser
import java.util.UUID
@@ -26,4 +27,20 @@ interface ItemPlaybackDao {
@Query("SELECT * from ItemPlayback WHERE userId=:userId")
fun getItems(userId: Int): List
+
+ @Query("SELECT * FROM ItemTrackModification WHERE userId=:userId AND itemId=:itemId")
+ suspend fun getTrackModifications(
+ userId: Int,
+ itemId: UUID,
+ ): List
+
+ @Query("SELECT * FROM ItemTrackModification WHERE userId=:userId AND itemId=:itemId AND trackIndex=:trackIndex")
+ suspend fun getTrackModifications(
+ userId: Int,
+ itemId: UUID,
+ trackIndex: Int,
+ ): ItemTrackModification?
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun saveItem(item: ItemTrackModification): Long
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt
index 97537146..00bc0fb7 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt
@@ -2,10 +2,14 @@ package com.github.damontecres.wholphin.data
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.ItemPlayback
+import com.github.damontecres.wholphin.data.model.ItemTrackModification
+import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
import com.github.damontecres.wholphin.data.model.TrackIndex
-import com.github.damontecres.wholphin.data.model.chooseSource
+import com.github.damontecres.wholphin.preferences.UserPreferences
+import com.github.damontecres.wholphin.services.StreamChoiceService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
+import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
@@ -13,6 +17,7 @@ import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
+import kotlin.time.Duration
@Singleton
class ItemPlaybackRepository
@@ -20,47 +25,60 @@ class ItemPlaybackRepository
constructor(
val serverRepository: ServerRepository,
val itemPlaybackDao: ItemPlaybackDao,
+ private val streamChoiceService: StreamChoiceService,
) {
- fun getSelectedTracks(
+ suspend fun getSelectedTracks(
itemId: UUID,
item: BaseItem,
+ prefs: UserPreferences,
): ChosenStreams? =
serverRepository.currentUser.value?.let { user ->
val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId)
- if (itemPlayback != null) {
- Timber.v("Got itemPlayback for %s", itemId)
- getChosenItemFromPlayback(item, itemPlayback)
- } else {
- null
- }
+ val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
+ Timber.v("For ${item.id}: itemPlayback=${itemPlayback != null}, plc=${plc != null}")
+ return getChosenItemFromPlayback(item, itemPlayback, plc, prefs)
}
fun getChosenItemFromPlayback(
item: BaseItem,
- itemPlayback: ItemPlayback,
+ itemPlayback: ItemPlayback?,
+ plc: PlaybackLanguageChoice?,
+ prefs: UserPreferences,
): ChosenStreams? {
val source =
- item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback.sourceId }
+ item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback?.sourceId }
+ ?: streamChoiceService.chooseSource(item.data.mediaSources.orEmpty())
if (source != null) {
val audioStream =
- if (itemPlayback.audioIndexEnabled) {
- source.mediaStreams?.firstOrNull { it.index == itemPlayback.audioIndex }
- } else {
- null
- }
+ streamChoiceService.chooseAudioStream(
+ candidates =
+ source.mediaStreams
+ ?.filter { it.type == MediaStreamType.AUDIO }
+ .orEmpty(),
+ itemPlayback = itemPlayback,
+ playbackLanguageChoice = plc,
+ prefs = prefs,
+ )
val subtitleStream =
- if (itemPlayback.subtitleIndexEnabled) {
- source.mediaStreams?.firstOrNull { it.index == itemPlayback.subtitleIndex }
- } else {
- null
- }
+ streamChoiceService.chooseSubtitleStream(
+ audioStream = audioStream,
+ candidates =
+ source.mediaStreams
+ ?.filter { it.type == MediaStreamType.SUBTITLE }
+ .orEmpty(),
+ itemPlayback = itemPlayback,
+ playbackLanguageChoice = plc,
+ prefs = prefs,
+ )
return ChosenStreams(
- itemPlayback,
- item.id,
- source.id?.toUUIDOrNull(),
- audioStream,
- subtitleStream,
- itemPlayback.subtitleIndex == TrackIndex.DISABLED,
+ itemPlayback = itemPlayback,
+ plc = plc,
+ itemId = item.id,
+ source = source,
+ videoStream = source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO },
+ audioStream = audioStream,
+ subtitleStream = subtitleStream,
+ subtitlesDisabled = itemPlayback?.subtitleIndex == TrackIndex.DISABLED,
)
} else {
return null
@@ -89,22 +107,58 @@ class ItemPlaybackRepository
itemPlayback: ItemPlayback?,
trackIndex: Int,
type: MediaStreamType,
- ) = serverRepository.currentUser.value?.let { user ->
- var toSave =
- itemPlayback ?: ItemPlayback(
- userId = user.rowId,
- itemId = item.id,
- sourceId = chooseSource(item.data, null)?.id?.toUUIDOrNull(),
- )
- toSave =
- when (type) {
- MediaStreamType.AUDIO -> toSave.copy(audioIndex = trackIndex)
- MediaStreamType.SUBTITLE -> toSave.copy(subtitleIndex = trackIndex)
- else -> toSave
+ ): ItemPlayback =
+ serverRepository.current.value!!.let { current ->
+ val source =
+ itemPlayback?.sourceId?.let { sourceId ->
+ item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == sourceId }
+ } ?: streamChoiceService.chooseSource(item.data, null)
+ if (source == null) {
+ Timber.w("Could not find media source for ${item.id}")
+ throw IllegalArgumentException("Could not find media source for ${item.id}")
}
- Timber.v("Saving track selection %s", toSave)
- saveItemPlayback(toSave)
- }
+ var toSave =
+ itemPlayback ?: ItemPlayback(
+ userId = current.user.rowId,
+ itemId = item.id,
+ sourceId = source.id?.toUUIDOrNull(),
+ )
+ toSave =
+ when (type) {
+ MediaStreamType.AUDIO -> toSave.copy(audioIndex = trackIndex)
+ MediaStreamType.SUBTITLE -> toSave.copy(subtitleIndex = trackIndex)
+ else -> toSave
+ }
+ Timber.v("Saving track selection %s", toSave)
+ toSave = saveItemPlayback(toSave)
+ val seriesId = item.data.seriesId
+ if (seriesId != null && trackIndex != TrackIndex.UNSPECIFIED) {
+ if (type == MediaStreamType.AUDIO) {
+ val stream = source.mediaStreams?.first { it.index == trackIndex }
+ if (stream?.language != null) {
+ streamChoiceService.updateAudio(item.data, stream.language!!)
+ }
+ } else if (type == MediaStreamType.SUBTITLE) {
+ if (trackIndex == TrackIndex.DISABLED) {
+ streamChoiceService.updateSubtitles(
+ item.data,
+ subtitleLang = null,
+ subtitlesDisabled = true,
+ )
+ } else {
+ val stream = source.mediaStreams?.first { it.index == trackIndex }
+ if (stream?.language != null) {
+ streamChoiceService.updateSubtitles(
+ item.data,
+ stream.language!!,
+ subtitlesDisabled = false,
+ )
+ }
+ }
+ }
+ }
+ toSave
+ }
/**
* Saves the [ItemPlayback] into the database, returning the same object with the rowId updated if needed
@@ -124,12 +178,40 @@ class ItemPlaybackRepository
val id = itemPlaybackDao.saveItem(toSave)
return toSave.copy(rowId = id)
}
+
+ suspend fun getTrackModifications(
+ itemId: UUID,
+ trackIndex: Int,
+ ): ItemTrackModification? =
+ serverRepository.currentUser.value?.rowId?.let { userId ->
+ itemPlaybackDao.getTrackModifications(userId, itemId, trackIndex)
+ }
+
+ suspend fun saveTrackModifications(
+ itemId: UUID,
+ trackIndex: Int,
+ delay: Duration,
+ ) {
+ serverRepository.currentUser.value?.rowId?.let { userId ->
+ Timber.v("Saving track mod item=%s, track=%s, delay=%s", itemId, trackIndex, delay)
+ itemPlaybackDao.saveItem(
+ ItemTrackModification(
+ userId,
+ itemId,
+ trackIndex,
+ delay.inWholeMilliseconds,
+ ),
+ )
+ }
+ }
}
data class ChosenStreams(
- val itemPlayback: ItemPlayback,
+ val itemPlayback: ItemPlayback?,
+ val plc: PlaybackLanguageChoice?,
val itemId: UUID,
- val sourceId: UUID?,
+ val source: MediaSourceInfo,
+ val videoStream: MediaStream?,
val audioStream: MediaStream?,
val subtitleStream: MediaStream?,
val subtitlesDisabled: Boolean,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt
new file mode 100644
index 00000000..e268a08c
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt
@@ -0,0 +1,20 @@
+package com.github.damontecres.wholphin.data
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
+import java.util.UUID
+
+@Dao
+interface PlaybackLanguageChoiceDao {
+ @Query("SELECT * FROM PlaybackLanguageChoice WHERE userId=:userId AND seriesId=:seriesId")
+ suspend fun get(
+ userId: Int,
+ seriesId: UUID,
+ ): PlaybackLanguageChoice?
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun save(plc: PlaybackLanguageChoice): Long
+}
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 5fc98724..48bea5b3 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
@@ -126,7 +126,9 @@ class ServerRepository
}
if (serverAndUsers != null) {
val user = serverAndUsers.users.firstOrNull { it.id == userId }
- if (user != null && !user.hasPin) {
+ if (user != null) {
+ // TODO pin-related
+// if (user != null && !user.hasPin) {
changeUser(serverAndUsers.server, user)
return true
}
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 49b85ef5..e5cd53bc 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
@@ -1,8 +1,10 @@
package com.github.damontecres.wholphin.data.model
+import com.github.damontecres.wholphin.ui.detail.CardGridItem
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.formatDateTime
import com.github.damontecres.wholphin.ui.nav.Destination
+import com.github.damontecres.wholphin.ui.playback.playable
import com.github.damontecres.wholphin.ui.seasonEpisode
import com.github.damontecres.wholphin.ui.seasonEpisodePadded
import com.github.damontecres.wholphin.ui.seriesProductionYears
@@ -18,8 +20,14 @@ import kotlin.time.Duration
data class BaseItem(
val data: BaseItemDto,
val useSeriesForPrimary: Boolean,
-) {
- val id get() = data.id
+) : CardGridItem {
+ override val id get() = data.id
+
+ override val playable: Boolean
+ get() = type.playable
+
+ override val sortName: String
+ get() = data.sortName ?: data.name ?: ""
val type get() = data.type
@@ -47,6 +55,9 @@ data class BaseItem(
}
}
+ @Transient
+ val aspectRatio: Float? = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 }
+
@Transient
val indexNumber = data.indexNumber ?: dateAsIndex()
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt
index e70f7c78..83f2d5df 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt
@@ -7,19 +7,10 @@ import androidx.room.ForeignKey
import androidx.room.Ignore
import androidx.room.Index
import androidx.room.PrimaryKey
-import com.github.damontecres.wholphin.preferences.UserPreferences
-import com.github.damontecres.wholphin.ui.isNotNullOrBlank
-import com.github.damontecres.wholphin.ui.letNotEmpty
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.UseSerializers
-import org.jellyfin.sdk.model.api.BaseItemDto
-import org.jellyfin.sdk.model.api.MediaSourceInfo
-import org.jellyfin.sdk.model.api.MediaStream
-import org.jellyfin.sdk.model.api.MediaStreamType
-import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
import org.jellyfin.sdk.model.serializer.UUIDSerializer
-import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID
@Entity(
@@ -53,122 +44,6 @@ data class ItemPlayback(
val subtitleIndexEnabled = subtitleIndex >= 0
}
-/**
- * Returns the [MediaSourceInfo] with the highest video resolution
- */
-fun chooseSource(sources: List?) =
- sources?.letNotEmpty { sources ->
- val result =
- sources.maxByOrNull { s ->
- s.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }?.let { video ->
- (video.width ?: 0) * (video.height ?: 0)
- } ?: 0
- }
- result
- }
-
-/**
- * Returns the [MediaSourceInfo] that matched the [ItemPlayback] or else the one with the highest resolution
- */
-fun chooseSource(
- dto: BaseItemDto,
- itemPlayback: ItemPlayback?,
-) = itemPlayback?.sourceId?.let { dto.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback.sourceId } }
- ?: chooseSource(dto.mediaSources) // dto.mediaSources?.firstOrNull()
-
-fun chooseStream(
- dto: BaseItemDto,
- itemPlayback: ItemPlayback?,
- type: MediaStreamType,
- prefs: UserPreferences,
-): MediaStream? {
- val source = chooseSource(dto, itemPlayback)
- return source?.mediaStreams?.letNotEmpty { streams ->
- val candidates = streams.filter { it.type == type }
- when (type) {
- MediaStreamType.AUDIO -> chooseAudioStream(candidates, itemPlayback, prefs)
- MediaStreamType.SUBTITLE -> chooseSubtitleStream(candidates, itemPlayback, prefs)
- else -> candidates.firstOrNull()
- }
- }
-}
-
-fun chooseAudioStream(
- candidates: List,
- itemPlayback: ItemPlayback?,
- prefs: UserPreferences,
-): MediaStream? =
- if (itemPlayback?.audioIndexEnabled == true) {
- candidates.firstOrNull { it.index == itemPlayback.audioIndex }
- } else {
- // TODO audio selection based on channel layout or preferences or default
- val audioLanguage = prefs.userConfig.audioLanguagePreference
- if (audioLanguage.isNotNullOrBlank()) {
- val sorted =
- candidates.sortedWith(compareBy { it.language }.thenByDescending { it.channels })
- sorted.firstOrNull { it.language == audioLanguage && it.isDefault }
- ?: sorted.firstOrNull { it.language == audioLanguage }
- ?: sorted.firstOrNull { it.isDefault }
- ?: sorted.firstOrNull()
- } else {
- candidates.firstOrNull { it.isDefault }
- ?: candidates.firstOrNull()
- }
- }
-
-fun chooseSubtitleStream(
- candidates: List,
- itemPlayback: ItemPlayback?,
- prefs: UserPreferences,
-): MediaStream? {
- if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) {
- return null
- } else if (itemPlayback?.subtitleIndexEnabled == true) {
- return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex }
- } else {
- val subtitleLanguage = prefs.userConfig.subtitleLanguagePreference
- return when (prefs.userConfig.subtitleMode) {
- SubtitlePlaybackMode.ALWAYS -> {
- if (subtitleLanguage != null) {
- candidates.firstOrNull { it.language == subtitleLanguage }
- } else {
- candidates.firstOrNull()
- }
- }
-
- SubtitlePlaybackMode.ONLY_FORCED -> {
- if (subtitleLanguage != null) {
- candidates.firstOrNull { it.language == subtitleLanguage && it.isForced }
- } else {
- candidates.firstOrNull { it.isForced }
- }
- }
-
- SubtitlePlaybackMode.SMART -> {
- val audioLanguage = prefs.userConfig.audioLanguagePreference
- if (audioLanguage != null && subtitleLanguage != null && audioLanguage != subtitleLanguage) {
- candidates.firstOrNull { it.language == subtitleLanguage }
- } else {
- null
- }
- }
-
- SubtitlePlaybackMode.DEFAULT -> {
- // TODO check for language?
- (
- candidates.firstOrNull { it.isDefault && it.isForced }
- ?: candidates.firstOrNull { it.isDefault }
- ?: candidates.firstOrNull { it.isForced }
- )
- }
-
- SubtitlePlaybackMode.NONE -> {
- null
- }
- }
- }
-}
-
object TrackIndex {
/**
* The user has not explicitly specified a track to use
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemTrackModification.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemTrackModification.kt
new file mode 100644
index 00000000..e8bbfeb4
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemTrackModification.kt
@@ -0,0 +1,30 @@
+@file:UseSerializers(UUIDSerializer::class)
+
+package com.github.damontecres.wholphin.data.model
+
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.UseSerializers
+import org.jellyfin.sdk.model.serializer.UUIDSerializer
+import java.util.UUID
+
+@Entity(
+ foreignKeys = [
+ ForeignKey(
+ entity = JellyfinUser::class,
+ parentColumns = arrayOf("rowId"),
+ childColumns = arrayOf("userId"),
+ onDelete = ForeignKey.CASCADE,
+ onUpdate = ForeignKey.CASCADE,
+ ),
+ ],
+ primaryKeys = ["userId", "itemId", "trackIndex"],
+)
+@Serializable
+data class ItemTrackModification(
+ val userId: Int,
+ val itemId: UUID,
+ val trackIndex: Int,
+ val delayMs: Long,
+)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt
new file mode 100644
index 00000000..f452e2f1
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt
@@ -0,0 +1,32 @@
+@file:UseSerializers(UUIDSerializer::class)
+
+package com.github.damontecres.wholphin.data.model
+
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.UseSerializers
+import org.jellyfin.sdk.model.serializer.UUIDSerializer
+import java.util.UUID
+
+@Entity(
+ foreignKeys = [
+ ForeignKey(
+ entity = JellyfinUser::class,
+ parentColumns = arrayOf("rowId"),
+ childColumns = arrayOf("userId"),
+ onDelete = ForeignKey.CASCADE,
+ onUpdate = ForeignKey.CASCADE,
+ ),
+ ],
+ primaryKeys = ["userId", "seriesId"],
+)
+@Serializable
+data class PlaybackLanguageChoice(
+ val userId: Int,
+ val seriesId: UUID,
+ val itemId: UUID? = null,
+ val audioLanguage: String? = null,
+ val subtitleLanguage: String? = null,
+ val subtitlesDisabled: Boolean? = null,
+)
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 9a196d6b..4d6154d6 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
@@ -659,6 +659,19 @@ sealed interface AppPreference {
summaryOff = R.string.disabled,
)
+ val BackdropStylePref =
+ AppChoicePreference(
+ title = R.string.backdrop_display,
+ defaultValue = BackdropStyle.BACKDROP_DYNAMIC_COLOR,
+ getter = { it.interfacePreferences.backdropStyle },
+ setter = { prefs, value ->
+ prefs.updateInterfacePreferences { backdropStyle = value }
+ },
+ displayValues = R.array.backdrop_style_options,
+ indexToValue = { BackdropStyle.forNumber(it) },
+ valueToIndex = { it.number },
+ )
+
val OneClickPause =
AppSwitchPreference(
title = R.string.one_click_pause,
@@ -784,6 +797,51 @@ sealed interface AppPreference {
}
},
)
+
+ val LiveTvShowHeader =
+ AppSwitchPreference(
+ title = R.string.show_details,
+ defaultValue = true,
+ getter = { it.interfacePreferences.liveTvPreferences.showHeader },
+ setter = { prefs, value ->
+ prefs.updateLiveTvPreferences { showHeader = value }
+ },
+ summaryOn = R.string.enabled,
+ summaryOff = R.string.disabled,
+ )
+ val LiveTvFavoriteChannelsBeginning =
+ AppSwitchPreference(
+ title = R.string.favorite_channels_at_beginning,
+ defaultValue = true,
+ getter = { it.interfacePreferences.liveTvPreferences.favoriteChannelsAtBeginning },
+ setter = { prefs, value ->
+ prefs.updateLiveTvPreferences { favoriteChannelsAtBeginning = value }
+ },
+ summaryOn = R.string.enabled,
+ summaryOff = R.string.disabled,
+ )
+ val LiveTvChannelSortByWatched =
+ AppSwitchPreference(
+ title = R.string.sort_channels_recently_watched,
+ defaultValue = false,
+ getter = { it.interfacePreferences.liveTvPreferences.sortByRecentlyWatched },
+ setter = { prefs, value ->
+ prefs.updateLiveTvPreferences { sortByRecentlyWatched = value }
+ },
+ summaryOn = R.string.enabled,
+ summaryOff = R.string.disabled,
+ )
+ val LiveTvColorCodePrograms =
+ AppSwitchPreference(
+ title = R.string.color_code_programs,
+ defaultValue = true,
+ getter = { it.interfacePreferences.liveTvPreferences.colorCodePrograms },
+ setter = { prefs, value ->
+ prefs.updateLiveTvPreferences { colorCodePrograms = value }
+ },
+ summaryOn = R.string.enabled,
+ summaryOff = R.string.disabled,
+ )
}
}
@@ -864,6 +922,7 @@ val advancedPreferences =
// Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418
// AppPreference.NavDrawerSwitchOnFocus,
AppPreference.ControllerTimeout,
+ AppPreference.BackdropStylePref,
),
),
)
@@ -948,6 +1007,14 @@ val advancedPreferences =
)
}
+val liveTvPreferences =
+ listOf(
+ AppPreference.LiveTvShowHeader,
+ AppPreference.LiveTvFavoriteChannelsBeginning,
+ AppPreference.LiveTvChannelSortByWatched,
+ AppPreference.LiveTvColorCodePrograms,
+ )
+
data class AppSwitchPreference(
@get:StringRes override val title: Int,
override val defaultValue: Boolean,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt
index e3223663..c39b9012 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt
@@ -91,6 +91,7 @@ class AppPreferencesSerializer
navDrawerSwitchOnFocus =
AppPreference.NavDrawerSwitchOnFocus.defaultValue
showClock = AppPreference.ShowClock.defaultValue
+ backdropStyle = AppPreference.BackdropStylePref.defaultValue
subtitlesPreferences =
SubtitlePreferences
@@ -98,6 +99,19 @@ class AppPreferencesSerializer
.apply {
resetSubtitles()
}.build()
+
+ liveTvPreferences =
+ LiveTvPreferences
+ .newBuilder()
+ .apply {
+ showHeader = AppPreference.LiveTvShowHeader.defaultValue
+ favoriteChannelsAtBeginning =
+ AppPreference.LiveTvFavoriteChannelsBeginning.defaultValue
+ sortByRecentlyWatched =
+ AppPreference.LiveTvChannelSortByWatched.defaultValue
+ colorCodePrograms =
+ AppPreference.LiveTvColorCodePrograms.defaultValue
+ }.build()
}.build()
advancedPreferences =
@@ -155,6 +169,11 @@ inline fun AppPreferences.updateSubtitlePreferences(block: SubtitlePreferences.B
subtitlesPreferences = subtitlesPreferences.toBuilder().apply(block).build()
}
+inline fun AppPreferences.updateLiveTvPreferences(block: LiveTvPreferences.Builder.() -> Unit): AppPreferences =
+ updateInterfacePreferences {
+ liveTvPreferences = liveTvPreferences.toBuilder().apply(block).build()
+ }
+
inline fun AppPreferences.updateAdvancedPreferences(block: AdvancedPreferences.Builder.() -> Unit): AppPreferences =
update {
advancedPreferences = advancedPreferences.toBuilder().apply(block).build()
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt
index eda96fa3..08dc0d77 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt
@@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.update
import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
+import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences
import com.github.damontecres.wholphin.preferences.updateMpvOptions
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
@@ -173,4 +174,27 @@ suspend fun upgradeApp(
}
}
}
+ if (previous.isEqualOrBefore(Version.fromString("0.3.5-56-g0"))) {
+ appPreferences.updateData {
+ it.updateLiveTvPreferences {
+ showHeader = AppPreference.LiveTvShowHeader.defaultValue
+ favoriteChannelsAtBeginning =
+ AppPreference.LiveTvFavoriteChannelsBeginning.defaultValue
+ sortByRecentlyWatched =
+ AppPreference.LiveTvChannelSortByWatched.defaultValue
+ colorCodePrograms =
+ AppPreference.LiveTvColorCodePrograms.defaultValue
+ }
+ }
+ }
+
+ if (previous.isEqualOrBefore(Version.fromString("0.3.6-52-g0"))) {
+ if (Build.MODEL.equals("shield android tv", ignoreCase = true)) {
+ appPreferences.updateData {
+ it.updateMpvOptions {
+ useGpuNext = false
+ }
+ }
+ }
+ }
}
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
new file mode 100644
index 00000000..cdbb7a2a
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt
@@ -0,0 +1,239 @@
+package com.github.damontecres.wholphin.services
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.util.LruCache
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.isSpecified
+import androidx.core.graphics.drawable.toBitmap
+import androidx.datastore.core.DataStore
+import androidx.palette.graphics.Palette
+import coil3.asDrawable
+import coil3.imageLoader
+import coil3.request.ImageRequest
+import coil3.request.SuccessResult
+import coil3.request.allowHardware
+import coil3.request.bitmapConfig
+import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.preferences.AppPreferences
+import com.github.damontecres.wholphin.preferences.BackdropStyle
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.delay
+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.ImageType
+import timber.log.Timber
+import java.util.UUID
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+@OptIn(FlowPreview::class)
+class BackdropService
+ @Inject
+ constructor(
+ @param:ApplicationContext private val context: Context,
+ private val imageUrlService: ImageUrlService,
+ private val preferences: DataStore,
+ ) {
+ private val extractedColorCache = LruCache(50)
+
+ private val _backdropFlow = MutableStateFlow(BackdropResult.NONE)
+ val backdropFlow = _backdropFlow
+
+ suspend fun submit(item: BaseItem) =
+ withContext(Dispatchers.IO) {
+ val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
+ if (backdropFlow.firstOrNull()?.imageUrl != imageUrl) {
+ _backdropFlow.update {
+ it.copy(
+ itemId = item.id,
+ imageUrl = null,
+ )
+ }
+ extractColors(item)
+ }
+ }
+
+ suspend fun clearBackdrop() {
+ _backdropFlow.update {
+ BackdropResult.NONE
+ }
+ }
+
+ private suspend fun extractColors(item: BaseItem) {
+ delay(500)
+ val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
+ val backdropStyle =
+ preferences.data
+ .firstOrNull()
+ ?.interfacePreferences
+ ?.backdropStyle
+ val dynamicEnabled =
+ backdropStyle == BackdropStyle.BACKDROP_DYNAMIC_COLOR ||
+ backdropStyle == BackdropStyle.UNRECOGNIZED
+ val (primaryColor, secondaryColor, tertiaryColor) =
+ if (dynamicEnabled) {
+ extractColorsFromBackdrop(imageUrl)
+ } else {
+ ExtractedColors.DEFAULT
+ }
+ _backdropFlow.update {
+ if (it.itemId == item.id) {
+ BackdropResult(
+ itemId = item.id,
+ imageUrl = imageUrl,
+ primaryColor = primaryColor,
+ secondaryColor = secondaryColor,
+ tertiaryColor = tertiaryColor,
+ )
+ } else {
+ it
+ }
+ }
+ }
+
+ private suspend fun extractColorsFromBackdrop(imageUrl: String?): ExtractedColors =
+ withContext(Dispatchers.IO) {
+ if (imageUrl.isNullOrBlank()) {
+ return@withContext ExtractedColors.DEFAULT
+ }
+ extractedColorCache.get(imageUrl)?.let {
+ return@withContext it
+ }
+
+ try {
+ val loader = context.imageLoader
+ val request =
+ ImageRequest
+ .Builder(context)
+ .data(imageUrl)
+ .allowHardware(false)
+ .bitmapConfig(Bitmap.Config.ARGB_8888)
+ .build()
+
+ val result = loader.execute(request)
+ if (result is SuccessResult) {
+ val drawable = result.image.asDrawable(context.resources)
+ val bitmap = drawable.toBitmap(config = Bitmap.Config.ARGB_8888)
+ extractColorsFromBitmap(bitmap).also {
+ extractedColorCache.put(imageUrl, it)
+ }
+ } else {
+ ExtractedColors.DEFAULT
+ }
+ } catch (e: Exception) {
+ Timber.e(e, "Error extracting colors from URL: $imageUrl")
+ ExtractedColors.DEFAULT
+ }
+ }
+
+ /**
+ * Helper function to determine if a color is "cool" (blue/purple/green) vs "warm" (red/orange/yellow)
+ *
+ * Cool colors have more blue/green than red
+ */
+ private val Palette.Swatch.coolColor: Boolean
+ get() {
+ val r = (rgb shr 16) and 0xFF
+ val g = (rgb shr 8) and 0xFF
+ val b = rgb and 0xFF
+ return b > r && (b + g) > (r * 1.5f)
+ }
+
+ private fun toColor(
+ swatch: Palette.Swatch?,
+ alpha: Float,
+ ): Color = swatch?.rgb?.let(::Color)?.copy(alpha = alpha) ?: Color.Transparent
+
+ /**
+ * Extracts colors from a bitmap using Android's Palette API.
+ *
+ * - Primary (Bottom-Right): darkVibrant -> darkMuted -> default
+ * - Secondary (Top-Left): Smart selection based on color temperature (prefers cool colors)
+ * - Tertiary (Top-Right): vibrant -> lightVibrant -> default
+ *
+ * @param bitmap The bitmap to extract colors from
+ * @return ExtractedColors containing primary, secondary, and tertiary colors
+ */
+ private suspend fun extractColorsFromBitmap(bitmap: Bitmap): ExtractedColors =
+ try {
+ val palette = Palette.from(bitmap).generate()
+
+ val vibrant = palette.vibrantSwatch
+ val darkVibrant = palette.darkVibrantSwatch
+ val lightVibrant = palette.lightVibrantSwatch
+ val muted = palette.mutedSwatch
+ val darkMuted = palette.darkMutedSwatch
+ val lightMuted = palette.lightMutedSwatch
+ val dominant = palette.dominantSwatch
+
+ // Primary (Bottom-Right)
+ val primaryColor = toColor(darkVibrant ?: darkMuted, .4f)
+
+ // Secondary (Top-Left): Smart selection based on color properties
+ // If Vibrant is cool (blue/purple), use it. If Vibrant is warm (yellow/orange) and Muted is cool, use Muted.
+ // This ensures we get cool tones (blue/purple) for top-left when available
+ val secondaryColor =
+ when {
+ vibrant != null && vibrant.coolColor -> vibrant
+ muted != null && muted.coolColor -> muted
+ vibrant != null -> vibrant
+ muted != null -> muted
+ else -> null
+ }.let { toColor(it, .4f) }
+
+ // Tertiary (Top-Right under image)
+ val tertiaryColor = toColor(vibrant ?: lightVibrant, .35f)
+
+ Timber.v(
+ "Colors extracted: primary=%s, secondary=%s, tertiary=%s",
+ primaryColor,
+ secondaryColor,
+ tertiaryColor,
+ )
+ ExtractedColors(primaryColor, secondaryColor, tertiaryColor)
+ } catch (e: Exception) {
+ Timber.e(e, "Error extracting palette colors")
+ ExtractedColors.DEFAULT
+ }
+
+ fun clearColorCache() {
+ extractedColorCache.evictAll()
+ }
+ }
+
+data class BackdropResult(
+ val itemId: UUID?,
+ val imageUrl: String?,
+ val primaryColor: Color = Color.Unspecified,
+ val secondaryColor: Color = Color.Unspecified,
+ val tertiaryColor: Color = Color.Unspecified,
+) {
+ val hasColors: Boolean =
+ primaryColor.isSpecified ||
+ secondaryColor.isSpecified ||
+ tertiaryColor.isSpecified
+
+ companion object {
+ val NONE =
+ BackdropResult(
+ null,
+ null,
+ )
+ }
+}
+
+data class ExtractedColors(
+ val primary: Color,
+ val secondary: Color,
+ val tertiary: Color,
+) {
+ companion object {
+ val DEFAULT = ExtractedColors(Color.Unspecified, Color.Unspecified, Color.Unspecified)
+ }
+}
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 ee7a4348..164035e7 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
@@ -31,7 +31,7 @@ class ImageUrlService
imageType: ImageType,
fillWidth: Int? = null,
fillHeight: Int? = null,
- ): String? =
+ ): String =
when (imageType) {
ImageType.BACKDROP,
ImageType.LOGO,
@@ -145,6 +145,8 @@ class ImageUrlService
imageIndex = imageIndex,
)
+ fun getUserImageUrl(userId: UUID) = api.imageApi.getUserImageUrl(userId)
+
/**
* Just a convenient way to get the image URL and remember it
*/
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 190bf21b..e34d2fa1 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
@@ -3,9 +3,12 @@ package com.github.damontecres.wholphin.services
import android.content.Context
import android.hardware.display.DisplayManager
import android.os.Build
+import android.os.Handler
+import android.os.Looper
import android.view.Display
import androidx.lifecycle.LiveData
import com.github.damontecres.wholphin.ui.setValueOnMain
+import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.EqualityMutableLiveData
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.delay
@@ -29,6 +32,8 @@ class RefreshRateService
private val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
private val originalMode = display.mode
+ val displayModes get() = display.supportedModes
+
private val _refreshRateMode = EqualityMutableLiveData(originalMode)
val refreshRateMode: LiveData = _refreshRateMode
@@ -57,12 +62,18 @@ class RefreshRateService
Timber.i("Found display mode: %s, current=${display.mode}", targetMode)
if (targetMode != null && targetMode != display.mode) {
val listener = Listener(display.displayId)
- displayManager.registerDisplayListener(listener, null)
+ displayManager.registerDisplayListener(
+ listener,
+ Handler(Looper.myLooper() ?: Looper.getMainLooper()),
+ )
_refreshRateMode.setValueOnMain(targetMode)
try {
- listener.latch.await(5, TimeUnit.SECONDS)
- } catch (_: InterruptedException) {
- Timber.w("Timed out waiting for display change")
+ if (!listener.latch.await(5, TimeUnit.SECONDS)) {
+ Timber.w("Timed out waiting for display change")
+ showToast(context, "Refresh rate switch is taking a long time")
+ }
+ } catch (ex: InterruptedException) {
+ Timber.w(ex, "Exception waiting for refresh rate switch")
}
val targetRate = (targetMode.refreshRate * 100).roundToInt()
val isSeamless =
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt
new file mode 100644
index 00000000..e1490ac0
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt
@@ -0,0 +1,248 @@
+package com.github.damontecres.wholphin.services
+
+import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
+import com.github.damontecres.wholphin.data.ServerRepository
+import com.github.damontecres.wholphin.data.model.ItemPlayback
+import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
+import com.github.damontecres.wholphin.data.model.TrackIndex
+import com.github.damontecres.wholphin.preferences.UserPreferences
+import com.github.damontecres.wholphin.ui.isNotNullOrBlank
+import com.github.damontecres.wholphin.ui.letNotEmpty
+import org.jellyfin.sdk.model.api.BaseItemDto
+import org.jellyfin.sdk.model.api.MediaSourceInfo
+import org.jellyfin.sdk.model.api.MediaStream
+import org.jellyfin.sdk.model.api.MediaStreamType
+import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
+import org.jellyfin.sdk.model.serializer.toUUIDOrNull
+import timber.log.Timber
+import java.util.UUID
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class StreamChoiceService
+ @Inject
+ constructor(
+ private val serverRepository: ServerRepository,
+ private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
+ ) {
+ suspend fun updateAudio(
+ dto: BaseItemDto,
+ audioLang: String,
+ ) = update(dto) {
+ it.copy(
+ audioLanguage = audioLang,
+ )
+ }
+
+ suspend fun updateSubtitles(
+ dto: BaseItemDto,
+ subtitleLang: String?,
+ subtitlesDisabled: Boolean,
+ ) = update(dto) {
+ it.copy(
+ subtitleLanguage = if (subtitlesDisabled) null else subtitleLang,
+ subtitlesDisabled = subtitlesDisabled,
+ )
+ }
+
+ suspend fun update(
+ dto: BaseItemDto,
+ update: (PlaybackLanguageChoice) -> PlaybackLanguageChoice,
+ ) {
+ val seriesId = dto.seriesId
+ if (seriesId != null) {
+ val userId = serverRepository.currentUser.value!!.rowId
+ val currentPlc =
+ playbackLanguageChoiceDao.get(userId, seriesId)
+ ?: PlaybackLanguageChoice(userId, seriesId, dto.id)
+ val newPlc = update.invoke(currentPlc)
+ Timber.v("Saving series PLC: %s", newPlc)
+ playbackLanguageChoiceDao.save(newPlc)
+ }
+ }
+
+ suspend fun getPlaybackLanguageChoice(dto: BaseItemDto) =
+ dto.seriesId?.let {
+ playbackLanguageChoiceDao.get(serverRepository.currentUser.value!!.rowId, it)
+ }
+
+ /**
+ * Returns the [MediaSourceInfo] that matched the [ItemPlayback] or else the one with the highest resolution
+ */
+ fun chooseSource(
+ dto: BaseItemDto,
+ itemPlayback: ItemPlayback?,
+ ): MediaSourceInfo? =
+ itemPlayback?.sourceId?.let { dto.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback.sourceId } }
+ ?: chooseSource(dto.mediaSources) // dto.mediaSources?.firstOrNull()
+
+ /**
+ * Returns the [MediaSourceInfo] with the highest video resolution
+ */
+ fun chooseSource(sources: List?) =
+ sources?.letNotEmpty { sources ->
+ val result =
+ sources.maxByOrNull { s ->
+ s.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }?.let { video ->
+ (video.width ?: 0) * (video.height ?: 0)
+ } ?: 0
+ }
+ result
+ }
+
+ suspend fun chooseAudioStream(
+ source: MediaSourceInfo,
+ seriesId: UUID?,
+ itemPlayback: ItemPlayback?,
+ plc: PlaybackLanguageChoice?,
+ prefs: UserPreferences,
+ ): MediaStream? {
+ val plc = plc ?: seriesId?.let { playbackLanguageChoiceDao.get(serverRepository.currentUser.value!!.rowId, it) }
+ return source.mediaStreams?.letNotEmpty { streams ->
+ val candidates = streams.filter { it.type == MediaStreamType.AUDIO }
+ chooseAudioStream(candidates, itemPlayback, plc, prefs)
+ }
+ }
+
+ fun chooseAudioStream(
+ candidates: List,
+ itemPlayback: ItemPlayback?,
+ playbackLanguageChoice: PlaybackLanguageChoice?,
+ prefs: UserPreferences,
+ ): MediaStream? =
+ if (itemPlayback?.audioIndexEnabled == true) {
+ candidates.firstOrNull { it.index == itemPlayback.audioIndex }
+ } else {
+ val seriesLang =
+ playbackLanguageChoice?.audioLanguage?.takeIf { it.isNotNullOrBlank() }
+ // If the user has chosen a different language for the series, prefer that
+ val audioLanguage = seriesLang ?: prefs.userConfig.audioLanguagePreference
+
+ if (audioLanguage.isNotNullOrBlank()) {
+ val sorted =
+ candidates.sortedWith(compareBy { it.language }.thenByDescending { it.channels })
+ sorted.firstOrNull { it.language == audioLanguage && it.isDefault }
+ ?: sorted.firstOrNull { it.language == audioLanguage }
+ ?: sorted.firstOrNull { it.isDefault }
+ ?: sorted.firstOrNull()
+ } else {
+ candidates.firstOrNull { it.isDefault }
+ ?: candidates.firstOrNull()
+ }
+ }
+
+ suspend fun chooseSubtitleStream(
+ source: MediaSourceInfo,
+ audioStream: MediaStream?,
+ seriesId: UUID?,
+ itemPlayback: ItemPlayback?,
+ plc: PlaybackLanguageChoice?,
+ prefs: UserPreferences,
+ ): MediaStream? {
+ val plc =
+ plc ?: seriesId?.let {
+ playbackLanguageChoiceDao.get(
+ serverRepository.currentUser.value!!.rowId,
+ it,
+ )
+ }
+ return source.mediaStreams?.letNotEmpty { streams ->
+ val candidates = streams.filter { it.type == MediaStreamType.SUBTITLE }
+ chooseSubtitleStream(
+ audioStream,
+ candidates,
+ itemPlayback,
+ plc,
+ prefs,
+ )
+ }
+ }
+
+ fun chooseSubtitleStream(
+ audioStream: MediaStream?,
+ candidates: List,
+ itemPlayback: ItemPlayback?,
+ playbackLanguageChoice: PlaybackLanguageChoice?,
+ prefs: UserPreferences,
+ ): MediaStream? {
+ if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) {
+ return null
+ } else if (itemPlayback?.subtitleIndexEnabled == true) {
+ return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex }
+ } else {
+ val seriesLang =
+ playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() }
+ val subtitleLanguage =
+ (seriesLang ?: prefs.userConfig.subtitleLanguagePreference)
+ ?.takeIf { it.isNotNullOrBlank() }
+
+ val subtitleMode =
+ when {
+ playbackLanguageChoice?.subtitlesDisabled == false && seriesLang != null -> {
+ // User has chosen a series level subtitle language, so override their normal
+ // subtitle mode to display that language
+ SubtitlePlaybackMode.ALWAYS
+ }
+
+ playbackLanguageChoice?.subtitlesDisabled == true && seriesLang == null -> {
+ // Series level settings disables subtitles
+ SubtitlePlaybackMode.NONE
+ }
+
+ else -> {
+ // Fallback to the user's preference
+ prefs.userConfig.subtitleMode
+ }
+ }
+ return when (subtitleMode) {
+ SubtitlePlaybackMode.ALWAYS -> {
+ if (subtitleLanguage != null) {
+ candidates.firstOrNull { it.language == subtitleLanguage }
+ } else {
+ candidates.firstOrNull()
+ }
+ }
+
+ SubtitlePlaybackMode.ONLY_FORCED -> {
+ if (subtitleLanguage != null) {
+ candidates.firstOrNull { it.language == subtitleLanguage && it.isForced }
+ } else {
+ candidates.firstOrNull { it.isForced }
+ }
+ }
+
+ SubtitlePlaybackMode.SMART -> {
+ val audioLanguage = prefs.userConfig.audioLanguagePreference
+ val audioStreamLang = audioStream?.language
+ if (audioLanguage.isNotNullOrBlank() && audioStreamLang.isNotNullOrBlank() && audioLanguage != audioStreamLang) {
+ candidates.firstOrNull { it.language == subtitleLanguage }
+ } else {
+ null
+ }
+ }
+
+ SubtitlePlaybackMode.DEFAULT -> {
+ subtitleLanguage?.let { lang ->
+ // Find best track that is in the preferred language
+ (
+ candidates.firstOrNull { it.isDefault && it.isForced && it.language == lang }
+ ?: candidates.firstOrNull { it.isDefault && it.language == lang }
+ ?: candidates.firstOrNull { it.isForced && it.language == lang }
+ )
+ }
+ ?: (
+ // If none in preferred language, just find the best track
+ candidates.firstOrNull { it.isDefault && it.isForced }
+ ?: candidates.firstOrNull { it.isDefault }
+ ?: candidates.firstOrNull { it.isForced }
+ )
+ }
+
+ SubtitlePlaybackMode.NONE -> {
+ null
+ }
+ }
+ }
+ }
+ }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt
new file mode 100644
index 00000000..a59f287b
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt
@@ -0,0 +1,27 @@
+package com.github.damontecres.wholphin.services
+
+import androidx.datastore.core.DataStore
+import com.github.damontecres.wholphin.data.ServerRepository
+import com.github.damontecres.wholphin.preferences.AppPreferences
+import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
+import com.github.damontecres.wholphin.preferences.UserPreferences
+import kotlinx.coroutines.flow.firstOrNull
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class UserPreferencesService
+ @Inject
+ constructor(
+ private val serverRepository: ServerRepository,
+ private val preferencesDataStore: DataStore,
+ ) {
+ suspend fun getCurrent(): UserPreferences =
+ serverRepository.currentUserDto.value!!.configuration.let { userConfig ->
+ val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
+ UserPreferences(
+ appPrefs,
+ userConfig ?: DefaultUserConfiguration,
+ )
+ }
+ }
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 a88c4ee8..66d8ca2f 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt
@@ -1,8 +1,6 @@
package com.github.damontecres.wholphin.services.hilt
-import android.annotation.SuppressLint
import android.content.Context
-import android.provider.Settings
import androidx.datastore.core.DataStore
import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.R
@@ -24,6 +22,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import org.jellyfin.sdk.Jellyfin
+import org.jellyfin.sdk.android.androidDevice
import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
import org.jellyfin.sdk.createJellyfin
@@ -61,14 +60,7 @@ object AppModule {
@Singleton
fun deviceInfo(
@ApplicationContext context: Context,
- ): DeviceInfo =
- DeviceInfo(
- id = @SuppressLint("HardwareIds") Settings.Secure.getString(
- context.contentResolver,
- Settings.Secure.ANDROID_ID,
- ),
- name = Settings.Global.getString(context.contentResolver, Settings.Global.DEVICE_NAME),
- )
+ ): DeviceInfo = androidDevice(context)
@StandardOkHttpClient
@Provides
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt
index dbf61912..30fb4422 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt
@@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.data.ItemPlaybackDao
import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
import com.github.damontecres.wholphin.data.Migrations
+import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
import com.github.damontecres.wholphin.data.ServerPreferencesDao
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
@@ -56,6 +57,10 @@ object DatabaseModule {
@Singleton
fun libraryDisplayInfoDao(db: AppDatabase): LibraryDisplayInfoDao = db.libraryDisplayInfoDao()
+ @Provides
+ @Singleton
+ fun playbackLanguageChoiceDao(db: AppDatabase): PlaybackLanguageChoiceDao = db.playbackLanguageChoiceDao()
+
@Provides
@Singleton
fun userPreferencesDataStore(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt
index 3efff666..899498fd 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt
@@ -8,8 +8,12 @@ import coil3.compose.setSingletonImageLoaderFactory
import coil3.disk.DiskCache
import coil3.disk.directory
import coil3.memory.MemoryCache
+import coil3.network.CacheStrategy
+import coil3.network.NetworkRequest
+import coil3.network.NetworkResponse
import coil3.network.cachecontrol.CacheControlCacheStrategy
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
+import coil3.request.Options
import coil3.request.crossfade
import coil3.util.DebugLogger
import okhttp3.OkHttpClient
@@ -67,10 +71,39 @@ fun CoilConfig(
.components {
add(
OkHttpNetworkFetcherFactory(
- cacheStrategy = { CacheControlCacheStrategy() },
+ cacheStrategy = { WholphinCacheStrategy(CacheControlCacheStrategy()) },
callFactory = { client },
),
)
}.build()
}
}
+
+/**
+ * This [CacheStrategy] always prefers the cached response for Trickplay images,
+ * otherwise the decision is delegated to the provided [CacheStrategy]
+ *
+ * The expectation is that Trickplay images will be prefetched so the cache will always be warm
+ */
+@OptIn(ExperimentalCoilApi::class)
+private class WholphinCacheStrategy(
+ private val delegate: CacheStrategy,
+) : CacheStrategy {
+ override suspend fun read(
+ cacheResponse: NetworkResponse,
+ networkRequest: NetworkRequest,
+ options: Options,
+ ): CacheStrategy.ReadResult =
+ if (networkRequest.url.contains("/Trickplay/")) {
+ CacheStrategy.ReadResult(cacheResponse)
+ } else {
+ delegate.read(cacheResponse, networkRequest, options)
+ }
+
+ override suspend fun write(
+ cacheResponse: NetworkResponse?,
+ networkRequest: NetworkRequest,
+ networkResponse: NetworkResponse,
+ options: Options,
+ ): CacheStrategy.WriteResult = delegate.write(cacheResponse, networkRequest, networkResponse, options)
+}
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 5af35e03..beafccb2 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
@@ -68,6 +68,7 @@ val SlimItemFields =
object Cards {
val height2x3 = 172.dp
val playedPercentHeight = 6.dp
+ val serverUserCircle = height2x3 * .75f
}
object AspectRatios {
@@ -75,6 +76,8 @@ object AspectRatios {
const val FOUR_THREE = 4f / 3f
const val TALL = 2f / 3f
const val SQUARE = 1f
+
+ const val MIN = TALL
}
enum class AspectRatio(
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 a7df3434..8cc3ad12 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
@@ -64,7 +64,7 @@ fun EpisodeCard(
} else {
focusedAfterDelay = false
}
- val aspectRatio = dto?.primaryImageAspectRatio?.toFloat() ?: AspectRatios.TALL
+ val aspectRatio = item?.aspectRatio?.coerceAtLeast(AspectRatios.MIN) ?: AspectRatios.MIN
val width = imageHeight * aspectRatio
val height = imageWidth * (1f / aspectRatio)
Column(
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
new file mode 100644
index 00000000..43d160e3
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt
@@ -0,0 +1,127 @@
+package com.github.damontecres.wholphin.ui.cards
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextDecoration
+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 coil3.compose.AsyncImage
+import coil3.request.ImageRequest
+import coil3.request.crossfade
+import com.github.damontecres.wholphin.ui.AspectRatios
+import com.github.damontecres.wholphin.ui.PreviewTvSpec
+import com.github.damontecres.wholphin.ui.components.Genre
+import com.github.damontecres.wholphin.ui.isNotNullOrBlank
+import com.github.damontecres.wholphin.ui.setup.rememberIdColor
+import com.github.damontecres.wholphin.ui.theme.WholphinTheme
+import timber.log.Timber
+import java.util.UUID
+
+@Composable
+fun GenreCard(
+ genre: Genre?,
+ onClick: () -> Unit,
+ onLongClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+) {
+ val background = rememberIdColor(genre?.id).copy(alpha = .6f)
+ Card(
+ modifier =
+ modifier,
+ onClick = onClick,
+ onLongClick = onLongClick,
+ interactionSource = interactionSource,
+ colors =
+ CardDefaults.colors(
+ containerColor = Color.Transparent,
+ ),
+ ) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier =
+ Modifier
+ .aspectRatio(AspectRatios.WIDE)
+ .fillMaxSize()
+ .clip(RoundedCornerShape(8.dp)),
+ ) {
+ Timber.v("genre image=${genre?.imageUrl}")
+ if (genre?.imageUrl.isNotNullOrBlank()) {
+ AsyncImage(
+ model =
+ ImageRequest
+ .Builder(LocalContext.current)
+ .data(genre.imageUrl)
+ .crossfade(true)
+ .build(),
+ contentScale = ContentScale.FillBounds,
+ contentDescription = null,
+ modifier =
+ Modifier
+ .alpha(.6f)
+ .aspectRatio(AspectRatios.WIDE)
+ .fillMaxSize(),
+ )
+ }
+ Box(
+ modifier =
+ Modifier
+ .aspectRatio(AspectRatios.WIDE)
+ .fillMaxSize()
+ .background(background),
+ ) {
+ Text(
+ text = genre?.name ?: "",
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center,
+ modifier =
+ Modifier
+ .padding(16.dp)
+ .align(Alignment.Center),
+ )
+ }
+ }
+ }
+}
+
+@PreviewTvSpec
+@Composable
+private fun GenreCardPreview() {
+ WholphinTheme {
+ val genre =
+ Genre(
+ UUID.randomUUID(),
+ "Adventure",
+ null,
+ Color.Black,
+ )
+ GenreCard(
+ genre = genre,
+ onClick = {},
+ onLongClick = {},
+ modifier = Modifier.width(180.dp),
+ )
+ }
+}
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 b6f3ff4f..1bc35a08 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
@@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.ui.cards
+import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -44,6 +45,7 @@ fun GridCard(
imageAspectRatio: Float = AspectRatios.TALL,
imageContentScale: ContentScale = ContentScale.Fit,
imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
+ showTitle: Boolean = true,
) {
val dto = item?.data
val focused by interactionSource.collectIsFocusedAsState()
@@ -94,38 +96,40 @@ fun GridCard(
modifier =
Modifier
.fillMaxWidth()
- .aspectRatio(imageAspectRatio)
+ .aspectRatio(imageAspectRatio.coerceAtLeast(AspectRatios.MIN))
.background(MaterialTheme.colorScheme.surfaceVariant),
)
}
- Column(
- verticalArrangement = Arrangement.spacedBy(0.dp),
- modifier =
- Modifier
- .padding(bottom = spaceBelow)
- .fillMaxWidth(),
- ) {
- Text(
- text = item?.title ?: "",
- maxLines = 1,
- textAlign = TextAlign.Center,
- overflow = TextOverflow.Ellipsis,
+ AnimatedVisibility(showTitle) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
- .fillMaxWidth()
- .padding(horizontal = 4.dp)
- .enableMarquee(focusedAfterDelay),
- )
- Text(
- text = item?.subtitle ?: "",
- maxLines = 1,
- textAlign = TextAlign.Center,
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(horizontal = 4.dp)
- .enableMarquee(focusedAfterDelay),
- )
+ .padding(bottom = spaceBelow)
+ .fillMaxWidth(),
+ ) {
+ Text(
+ text = item?.title ?: "",
+ maxLines = 1,
+ textAlign = TextAlign.Center,
+ overflow = TextOverflow.Ellipsis,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 4.dp)
+ .enableMarquee(focusedAfterDelay),
+ )
+ Text(
+ text = item?.subtitle ?: "",
+ 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/ItemRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt
index 26bc9ce5..618b9003 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
@@ -113,8 +113,7 @@ fun BannerItemRow(
name = title,
item = item,
aspectRatio =
- aspectRatioOverride ?: item?.data?.primaryImageAspectRatio?.toFloat()
- ?: AspectRatios.WIDE,
+ aspectRatioOverride ?: item?.aspectRatio ?: AspectRatios.WIDE,
cornerText = item?.data?.indexNumber?.let { "E$it" },
played = item?.data?.userData?.played ?: false,
playPercent = item?.data?.userData?.playedPercentage ?: 0.0,
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 66f93212..243bb6d6 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
@@ -46,7 +46,7 @@ fun SeasonCard(
imageWidth: Dp = Dp.Unspecified,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
showImageOverlay: Boolean = false,
- aspectRatio: Float = item?.data?.primaryImageAspectRatio?.toFloat() ?: AspectRatios.TALL,
+ aspectRatio: Float = item?.aspectRatio ?: AspectRatios.TALL,
) {
val imageUrlService = LocalImageUrlService.current
val density = LocalDensity.current
@@ -139,8 +139,9 @@ fun SeasonCard(
} else {
focusedAfterDelay = false
}
- val width = imageHeight * aspectRatio
- val height = imageWidth * (1f / aspectRatio)
+ val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
+ val width = imageHeight * aspectRationToUse
+ val height = imageWidth * (1f / aspectRationToUse)
Column(
verticalArrangement = Arrangement.spacedBy(spaceBetween),
modifier = modifier.size(width, height),
@@ -149,7 +150,7 @@ fun SeasonCard(
modifier =
Modifier
.size(imageWidth, imageHeight)
- .aspectRatio(aspectRatio),
+ .aspectRatio(aspectRationToUse),
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
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 22591db4..f52f3970 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
@@ -62,6 +62,7 @@ import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
import com.github.damontecres.wholphin.preferences.UserPreferences
+import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.AspectRatios
@@ -76,6 +77,7 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
+import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.HomePageHeader
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.scale
@@ -123,6 +125,7 @@ class CollectionFolderViewModel
private val serverRepository: ServerRepository,
private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
private val favoriteWatchManager: FavoriteWatchManager,
+ private val backdropService: BackdropService,
val navigationManager: NavigationManager,
) : ItemViewModel(api) {
val loading = MutableLiveData(LoadingState.Loading)
@@ -202,6 +205,9 @@ class CollectionFolderViewModel
this.viewOptions.value = viewOptions
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
saveLibraryDisplayInfo(viewOptions = viewOptions)
+ if (!viewOptions.showDetails) {
+ backdropService.clearBackdrop()
+ }
}
}
@@ -246,13 +252,26 @@ class CollectionFolderViewModel
this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection
this@CollectionFolderViewModel.filter.value = filter
}
- val newPager = createPager(sortAndDirection, recursive, filter, useSeriesForPrimary)
- newPager.init()
- if (newPager.isNotEmpty()) newPager.getBlocking(0)
- withContext(Dispatchers.Main) {
- pager.value = newPager
- loading.value = LoadingState.Success
- backgroundLoading.value = LoadingState.Success
+ try {
+ val newPager =
+ createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init()
+ if (newPager.isNotEmpty()) newPager.getBlocking(0)
+ withContext(Dispatchers.Main) {
+ pager.value = newPager
+ loading.value = LoadingState.Success
+ 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 = LoadingState.Error(ex)
+ pager.value = listOf()
+ }
}
}
}
@@ -454,6 +473,12 @@ class CollectionFolderViewModel
favoriteWatchManager.setFavorite(itemId, favorite)
(pager.value as? ApiRequestPager<*>)?.refreshItem(position, itemId)
}
+
+ fun updateBackdrop(item: BaseItem) {
+ viewModelScope.launchIO {
+ backdropService.submit(item)
+ }
+ }
}
/**
@@ -505,7 +530,7 @@ fun CollectionFolderGrid(
playEnabled: Boolean,
defaultViewOptions: ViewOptions,
modifier: Modifier = Modifier,
- viewModel: CollectionFolderViewModel = hiltViewModel(),
+ viewModel: CollectionFolderViewModel = hiltViewModel(key = itemId),
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
initialSortAndDirection: SortAndDirection? = null,
showTitle: Boolean = true,
@@ -578,8 +603,12 @@ fun CollectionFolderGrid(
viewOptions = viewOptions,
defaultViewOptions = defaultViewOptions,
onSaveViewOptions = { viewModel.saveViewOptions(it) },
+ onChangeBackdrop = viewModel::updateBackdrop,
playEnabled = playEnabled,
- onClickPlay = { shuffle ->
+ onClickPlay = { _, item ->
+ viewModel.navigationManager.navigateTo(Destination.Playback(item))
+ },
+ onClickPlayAll = { shuffle ->
itemId.toUUIDOrNull()?.let {
viewModel.navigationManager.navigateTo(
Destination.PlaybackList(
@@ -678,17 +707,19 @@ fun CollectionFolderGridContent(
letterPosition: suspend (Char) -> Int,
sortOptions: List,
playEnabled: Boolean,
- onClickPlay: (shuffle: Boolean) -> Unit,
+ getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List,
+ defaultViewOptions: ViewOptions,
+ onSaveViewOptions: (ViewOptions) -> Unit,
+ viewOptions: ViewOptions,
+ onClickPlayAll: (shuffle: Boolean) -> Unit,
+ onClickPlay: (Int, BaseItem) -> Unit,
+ onChangeBackdrop: (BaseItem) -> Unit,
modifier: Modifier = Modifier,
showTitle: Boolean = true,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
currentFilter: GetItemsFilter = GetItemsFilter(),
filterOptions: List> = listOf(),
onFilterChange: (GetItemsFilter) -> Unit = {},
- getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List,
- defaultViewOptions: ViewOptions,
- viewOptions: ViewOptions,
- onSaveViewOptions: (ViewOptions) -> Unit,
) {
val context = LocalContext.current
val title = item?.name ?: item?.data?.collectionType?.name ?: stringResource(R.string.collection)
@@ -703,14 +734,13 @@ fun CollectionFolderGridContent(
var position by rememberInt(0)
val focusedItem = pager.getOrNull(position)
+ if (viewOptions.showDetails) {
+ LaunchedEffect(focusedItem) {
+ focusedItem?.let(onChangeBackdrop)
+ }
+ }
Box(modifier = modifier) {
- if (viewOptions.showDetails) {
- DelayedDetailsBackdropImage(
- item = focusedItem,
- modifier = Modifier,
- )
- }
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = Modifier.fillMaxSize(),
@@ -786,12 +816,12 @@ fun CollectionFolderGridContent(
title = R.string.play,
resume = Duration.ZERO,
icon = Icons.Default.PlayArrow,
- onClick = { onClickPlay.invoke(false) },
+ onClick = { onClickPlayAll.invoke(false) },
)
ExpandableFaButton(
title = R.string.shuffle,
iconStringRes = R.string.fa_shuffle,
- onClick = { onClickPlay.invoke(true) },
+ onClick = { onClickPlayAll.invoke(true) },
)
}
}
@@ -813,6 +843,7 @@ fun CollectionFolderGridContent(
pager = pager,
onClickItem = onClickItem,
onLongClickItem = onLongClickItem,
+ onClickPlay = onClickPlay,
letterPosition = letterPosition,
gridFocusRequester = gridFocusRequester,
showJumpButtons = false, // TODO add preference
@@ -832,6 +863,7 @@ fun CollectionFolderGridContent(
imageContentScale = viewOptions.contentScale.scale,
imageAspectRatio = viewOptions.aspectRatio.ratio,
imageType = viewOptions.imageType,
+ showTitle = viewOptions.showTitles,
modifier = mod,
)
},
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/DetailsBackdropImage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/DetailsBackdropImage.kt
deleted file mode 100644
index eea7fa90..00000000
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/DetailsBackdropImage.kt
+++ /dev/null
@@ -1,154 +0,0 @@
-package com.github.damontecres.wholphin.ui.components
-
-import androidx.compose.foundation.layout.BoxScope
-import androidx.compose.foundation.layout.fillMaxHeight
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.alpha
-import androidx.compose.ui.draw.drawWithContent
-import androidx.compose.ui.graphics.Brush
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.platform.LocalContext
-import androidx.tv.material3.MaterialTheme
-import coil3.compose.AsyncImage
-import coil3.request.ImageRequest
-import coil3.request.transitionFactory
-import com.github.damontecres.wholphin.data.model.BaseItem
-import com.github.damontecres.wholphin.ui.CrossFadeFactory
-import com.github.damontecres.wholphin.ui.LocalImageUrlService
-import com.github.damontecres.wholphin.ui.isNotNullOrBlank
-import kotlinx.coroutines.delay
-import org.jellyfin.sdk.model.api.ImageType
-import kotlin.time.Duration.Companion.milliseconds
-
-@Composable
-fun BoxScope.DetailsBackdropImage(
- item: BaseItem?,
- modifier: Modifier = Modifier,
-) {
- val imageUrlService = LocalImageUrlService.current
- val backdropImageUrl =
- remember(item) {
- if (item != null) {
- imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
- } else {
- null
- }
- }
- DetailsBackdropImage(backdropImageUrl, modifier)
-}
-
-@Composable
-fun BoxScope.DetailsBackdropImage(
- backdropImageUrl: String?,
- modifier: Modifier = Modifier,
-) {
- if (backdropImageUrl.isNotNullOrBlank()) {
- val gradientColor = MaterialTheme.colorScheme.background
- AsyncImage(
- model = backdropImageUrl,
- contentDescription = null,
- contentScale = ContentScale.Fit,
- alignment = Alignment.TopEnd,
- modifier =
- modifier
- .align(Alignment.TopEnd)
- .fillMaxHeight(.85f)
- .alpha(.75f)
- .drawWithContent {
- drawContent()
- drawRect(
- Brush.verticalGradient(
- colors = listOf(Color.Transparent, gradientColor),
- startY = size.height * .5f,
- ),
- )
- drawRect(
- Brush.horizontalGradient(
- colors = listOf(Color.Transparent, gradientColor),
- endX = 0f,
- startX = size.width * .75f,
- ),
- )
- },
- )
- }
-}
-
-@Composable
-fun BoxScope.DelayedDetailsBackdropImage(
- item: BaseItem?,
- modifier: Modifier = Modifier,
-) {
- val imageUrlService = LocalImageUrlService.current
- val backdropImageUrl =
- remember(item) {
- if (item != null) {
- imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
- } else {
- null
- }
- }
- DelayedDetailsBackdropImage(backdropImageUrl, modifier)
-}
-
-/**
- * Shows a backdrop image, but with a crossfade & delay
- *
- * Used for change backdrops when change items frequently
- */
-@Composable
-fun BoxScope.DelayedDetailsBackdropImage(
- focusedBackdropImageUrl: String?,
- modifier: Modifier = Modifier,
-) {
- val context = LocalContext.current
- var backdropImageUrl by remember { mutableStateOf(null) }
- LaunchedEffect(focusedBackdropImageUrl) {
- backdropImageUrl = null
- delay(150)
- backdropImageUrl = focusedBackdropImageUrl
- }
- val gradientColor = MaterialTheme.colorScheme.background
- AsyncImage(
- model =
- ImageRequest
- .Builder(context)
- .data(backdropImageUrl)
- .transitionFactory(CrossFadeFactory(250.milliseconds))
- .build(),
- contentDescription = null,
- contentScale = ContentScale.Fit,
- alignment = Alignment.TopEnd,
- modifier =
- modifier
- .fillMaxHeight(.7f)
- .fillMaxWidth(.7f)
- .alpha(.75f)
- .align(Alignment.TopEnd)
- .drawWithContent {
- drawContent()
- drawRect(
- Brush.verticalGradient(
- colors = listOf(Color.Transparent, gradientColor),
- startY = size.height * .33f,
- ),
- )
- drawRect(
- Brush.horizontalGradient(
- colors = listOf(gradientColor, Color.Transparent),
- startX = 0f,
- endX = size.width * .5f,
- ),
- )
- },
- )
-}
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 6f723f10..bfb098d5 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
@@ -310,6 +310,9 @@ fun DialogPopup(
@Composable
fun ScrollableDialog(
onDismissRequest: () -> Unit,
+ width: Dp = 600.dp,
+ maxHeight: Dp = 380.dp,
+ itemSpacing: Dp = 8.dp,
content: LazyListScope.() -> Unit,
) {
val scrollAmount = 100f
@@ -331,12 +334,12 @@ fun ScrollableDialog(
LazyColumn(
state = columnState,
contentPadding = PaddingValues(16.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(itemSpacing),
content = content,
modifier =
Modifier
- .width(600.dp)
- .heightIn(max = 380.dp)
+ .width(width)
+ .heightIn(max = maxHeight)
.focusable()
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/EditTextBox.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/EditTextBox.kt
index 67faa648..a85d9fc9 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/EditTextBox.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/EditTextBox.kt
@@ -5,33 +5,43 @@ 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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicSecureTextField
import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.KeyboardActionHandler
import androidx.compose.foundation.text.input.TextFieldDecorator
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.rememberTextFieldState
+import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.material3.TextFieldDefaults.Container
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Icon
import androidx.tv.material3.LocalContentColor
@@ -39,6 +49,7 @@ import androidx.tv.material3.MaterialTheme
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
+import com.google.protobuf.value
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -191,6 +202,159 @@ fun SearchEditTextBox(
)
}
+/**
+ * A modified [BasicTextField] that looks & fits better with TV material controls
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun EditTextBox(
+ value: String,
+ onValueChange: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
+ keyboardActions: KeyboardActions = KeyboardActions.Default,
+ leadingIcon: @Composable (() -> Unit)? = null,
+ enabled: Boolean = true,
+ readOnly: Boolean = false,
+ height: Dp = 40.dp,
+ isInputValid: (String) -> Boolean = { true },
+ supportingText: @Composable (() -> Unit)? = null,
+ placeholder: @Composable (() -> Unit)? = null,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+) {
+ val colors =
+ TextFieldDefaults.colors(
+ unfocusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
+ focusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f),
+ disabledTextColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.25f),
+ disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.25f),
+ errorContainerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.75f),
+ errorTextColor = MaterialTheme.colorScheme.onErrorContainer,
+ )
+ CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) {
+ BasicTextField(
+ value = value,
+ modifier =
+ modifier
+ .defaultMinSize(
+ minWidth = TextFieldDefaults.MinWidth,
+ minHeight = height,
+ ).height(height),
+ onValueChange = onValueChange,
+ enabled = enabled,
+ readOnly = readOnly,
+ textStyle = MaterialTheme.typography.bodyLarge.merge(MaterialTheme.colorScheme.onPrimaryContainer),
+ cursorBrush = SolidColor(colors.cursorColor),
+ keyboardOptions = keyboardOptions,
+ keyboardActions = keyboardActions,
+ interactionSource = interactionSource,
+ singleLine = true,
+ maxLines = 1,
+ minLines = 1,
+ visualTransformation =
+ if (keyboardOptions.keyboardType == KeyboardType.Password ||
+ keyboardOptions.keyboardType == KeyboardType.NumberPassword
+ ) {
+ PasswordVisualTransformation()
+ } else {
+ VisualTransformation.None
+ },
+ decorationBox =
+ @Composable { innerTextField ->
+ // places leading icon, text field with label and placeholder, trailing icon
+ TextFieldDefaults.DecorationBox(
+ value = value,
+ visualTransformation =
+ if (keyboardOptions.keyboardType == KeyboardType.Password ||
+ keyboardOptions.keyboardType == KeyboardType.NumberPassword
+ ) {
+ PasswordVisualTransformation()
+ } else {
+ VisualTransformation.None
+ },
+ innerTextField = innerTextField,
+ placeholder = placeholder,
+ label = null,
+ leadingIcon = leadingIcon,
+ trailingIcon = null,
+ prefix = null,
+ suffix = null,
+ supportingText = supportingText,
+ shape = CircleShape,
+ singleLine = true,
+ enabled = enabled,
+ isError = false,
+ interactionSource = interactionSource,
+ colors = colors,
+ contentPadding =
+ PaddingValues(
+ horizontal = 8.dp,
+ vertical = 10.dp,
+ ),
+ container = {
+ Container(
+ enabled = enabled,
+ isError = !isInputValid.invoke(value),
+ interactionSource = interactionSource,
+ modifier = Modifier,
+ colors = colors,
+ shape = CircleShape,
+ focusedIndicatorLineThickness = 4.dp,
+ unfocusedIndicatorLineThickness = 0.dp,
+ )
+ },
+ )
+ },
+ )
+ }
+}
+
+/**
+ * And [EditTextBox] styles for searches
+ */
+@Composable
+fun SearchEditTextBox(
+ value: String,
+ onValueChange: (String) -> Unit,
+ onSearchClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ readOnly: Boolean = false,
+ height: Dp = 40.dp,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+) {
+ EditTextBox(
+ value,
+ onValueChange,
+ modifier,
+ keyboardOptions =
+ KeyboardOptions(
+ autoCorrectEnabled = false,
+ imeAction = ImeAction.Search,
+ ),
+ keyboardActions =
+ KeyboardActions(
+ onSearch = {
+ onSearchClick.invoke()
+ this.defaultKeyboardAction(ImeAction.Search)
+ },
+ ),
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Search,
+ contentDescription = stringResource(R.string.search),
+ tint = MaterialTheme.colorScheme.onPrimaryContainer,
+ )
+ },
+ enabled = enabled,
+ readOnly = readOnly,
+ height = height,
+ interactionSource = interactionSource,
+ )
+}
+
@PreviewTvSpec
@Composable
private fun EditTextBoxPreview() {
@@ -199,6 +363,10 @@ private fun EditTextBoxPreview() {
EditTextBox(
state = rememberTextFieldState("string"),
)
+ EditTextBox(
+ value = "string",
+ onValueChange = {},
+ )
SearchEditTextBox(
state = rememberTextFieldState("search query"),
onSearchClick = { },
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 b3711d0a..f4fea70c 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
@@ -10,30 +10,43 @@ 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.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter
+import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.SlimItemFields
-import com.github.damontecres.wholphin.ui.cards.GridCard
+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.ApiRequestPager
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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
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.BaseItemKind
+import org.jellyfin.sdk.model.api.ImageType
+import org.jellyfin.sdk.model.api.ItemFields
+import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.request.GetGenresRequest
+import org.jellyfin.sdk.model.api.request.GetItemsRequest
import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
@HiltViewModel
@@ -41,11 +54,12 @@ class GenreViewModel
@Inject
constructor(
private val api: ApiClient,
+ private val imageUrlService: ImageUrlService,
val navigationManager: NavigationManager,
) : ViewModel() {
private lateinit var itemId: UUID
val loading = MutableLiveData(LoadingState.Pending)
- val genres = MutableLiveData>(listOf())
+ val genres = MutableLiveData>(listOf())
fun init(itemId: UUID) {
loading.value = LoadingState.Loading
@@ -56,11 +70,69 @@ class GenreViewModel
parentId = itemId,
fields = SlimItemFields,
)
- val pager = ApiRequestPager(api, request, GetGenresRequestHandler, viewModelScope).init()
+ val genres =
+ GetGenresRequestHandler
+ .execute(api, request)
+ .content.items
+ .map {
+ Genre(it.id, it.name ?: "", null, Color.Black)
+ }
+// val pager = ApiRequestPager(api, request, GetGenresRequestHandler, viewModelScope).init()
withContext(Dispatchers.Main) {
- genres.value = pager
+ this@GenreViewModel.genres.value = genres
loading.value = LoadingState.Success
}
+// val excludeItemIds = mutableSetOf()
+ val genreToUrl = ConcurrentHashMap()
+ val semaphore = Semaphore(4)
+ genres
+ .map { genre ->
+ viewModelScope.async(Dispatchers.IO) {
+ semaphore.withPermit {
+ val item =
+ GetItemsRequestHandler
+ .execute(
+ api,
+ GetItemsRequest(
+// excludeItemIds = excludeItemIds,
+ parentId = itemId,
+ recursive = true,
+ limit = 1,
+ sortBy = listOf(ItemSortBy.RANDOM),
+ fields = listOf(ItemFields.GENRES),
+ imageTypes = listOf(ImageType.THUMB),
+ imageTypeLimit = 1,
+ includeItemTypes =
+ listOf(
+ BaseItemKind.MOVIE,
+ BaseItemKind.SERIES,
+ ),
+ genreIds = listOf(genre.id),
+ enableTotalRecordCount = false,
+ ),
+ ).content.items
+ .firstOrNull()
+ if (item != null) {
+// excludeItemIds.add(item.id)
+ genreToUrl[genre.id] =
+ imageUrlService.getItemImageUrl(
+ item.id,
+ item.type,
+ null,
+ false,
+ ImageType.THUMB,
+ )
+ }
+ }
+ }
+ }.awaitAll()
+ val genresWithImages =
+ genres.map {
+ it.copy(
+ imageUrl = genreToUrl[it.id],
+ )
+ }
+ this@GenreViewModel.genres.setValueOnMain(genresWithImages)
}
}
@@ -78,6 +150,16 @@ class GenreViewModel
}
}
+data class Genre(
+ override val id: UUID,
+ val name: String,
+ val imageUrl: String?,
+ val color: Color,
+) : CardGridItem {
+ override val playable: Boolean = false
+ override val sortName: String get() = name
+}
+
@Composable
fun GenreCardGrid(
itemId: UUID,
@@ -117,6 +199,7 @@ fun GenreCardGrid(
)
},
onLongClickItem = { _, _ -> },
+ onClickPlay = { _, _ -> },
letterPosition = { viewModel.positionOfLetter(it) },
gridFocusRequester = gridFocusRequester,
showJumpButtons = false,
@@ -125,13 +208,13 @@ fun GenreCardGrid(
initialPosition = 0,
positionCallback = { columns, position ->
},
- cardContent = { item: BaseItem?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier ->
- GridCard(
- item = item,
+ columns = 4,
+ cardContent = { item: Genre?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier ->
+ GenreCard(
+ genre = item,
onClick = onClick,
onLongClick = onLongClick,
modifier = mod,
- imageAspectRatio = 1f,
)
},
)
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 cf27554d..1e274de6 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
@@ -124,6 +124,7 @@ fun ItemGrid(
viewModel.navigateTo(Destination.Playback(item.id, 0))
},
onLongClickItem = { index: Int, item: BaseItem -> },
+ onClickPlay = { _, item -> viewModel.navigateTo(Destination.Playback(item)) },
letterPosition = { c: Char -> 0 },
gridFocusRequester = focusRequester,
showJumpButtons = false,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/MovieComponents.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/MovieComponents.kt
index 30e0a902..e25f50be 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/MovieComponents.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/MovieComponents.kt
@@ -1,29 +1,32 @@
package com.github.damontecres.wholphin.ui.components
+import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
import androidx.tv.material3.MaterialTheme
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.ui.TimeFormatter
import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.ui.timeRemaining
+import com.github.damontecres.wholphin.ui.util.LocalClock
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.extensions.ticks
+import java.time.LocalDateTime
@Composable
fun MovieQuickDetails(
dto: BaseItemDto?,
modifier: Modifier = Modifier,
) {
+ val context = LocalContext.current
+ val now = LocalClock.current.now
val details =
- remember(dto) {
+ remember(dto, now) {
buildList {
dto?.productionYear?.let { add(it.toString()) }
- dto?.runTimeTicks?.ticks?.roundMinutes?.let {
- add(it.toString())
- }
- dto?.timeRemaining?.roundMinutes?.let {
- add("$it left")
- }
+ addRuntimeDetails(context, now, dto)
dto?.officialRating?.let(::add)
}
}
@@ -36,3 +39,21 @@ fun MovieQuickDetails(
modifier = modifier,
)
}
+
+fun MutableList.addRuntimeDetails(
+ context: Context,
+ now: LocalDateTime,
+ dto: BaseItemDto?,
+) {
+ val runtime = dto?.runTimeTicks?.ticks
+ runtime?.let { duration ->
+ add(duration.roundMinutes.toString())
+ }
+ dto?.timeRemaining?.roundMinutes?.let {
+ add("$it left")
+ }
+ (dto?.timeRemaining ?: runtime)?.let { remaining ->
+ val endTimeStr = TimeFormatter.format(now.plusSeconds(remaining.inWholeSeconds))
+ add(context.getString(R.string.ends_at, endTimeStr))
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt
index 6a14fdbf..0f99f8bc 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt
@@ -78,39 +78,37 @@ fun ExpandablePlayButtons(
if (resumePosition > Duration.ZERO) {
item("play") {
ExpandablePlayButton(
- R.string.resume,
- resumePosition,
- Icons.Default.PlayArrow,
- playOnClick,
- Modifier
- .onFocusChanged(buttonOnFocusChanged)
- .focusRequester(firstFocus)
- .animateItem(),
+ title = R.string.resume,
+ resume = resumePosition,
+ icon = Icons.Default.PlayArrow,
+ onClick = playOnClick,
+ modifier =
+ Modifier
+ .onFocusChanged(buttonOnFocusChanged)
+ .focusRequester(firstFocus),
)
}
item("restart") {
ExpandablePlayButton(
- R.string.restart,
- Duration.ZERO,
- Icons.Default.Refresh,
- playOnClick,
- Modifier
- .onFocusChanged(buttonOnFocusChanged)
- .animateItem(),
+ title = R.string.restart,
+ resume = Duration.ZERO,
+ icon = Icons.Default.Refresh,
+ onClick = playOnClick,
+ modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
mirrorIcon = true,
)
}
} else {
item("play") {
ExpandablePlayButton(
- R.string.play,
- Duration.ZERO,
- Icons.Default.PlayArrow,
- playOnClick,
- Modifier
- .onFocusChanged(buttonOnFocusChanged)
- .focusRequester(firstFocus)
- .animateItem(),
+ title = R.string.play,
+ resume = Duration.ZERO,
+ icon = Icons.Default.PlayArrow,
+ onClick = playOnClick,
+ modifier =
+ Modifier
+ .onFocusChanged(buttonOnFocusChanged)
+ .focusRequester(firstFocus),
)
}
}
@@ -121,10 +119,7 @@ fun ExpandablePlayButtons(
title = if (watched) R.string.mark_unwatched else R.string.mark_watched,
iconStringRes = if (watched) R.string.fa_eye else R.string.fa_eye_slash,
onClick = watchOnClick,
- modifier =
- Modifier
- .onFocusChanged(buttonOnFocusChanged)
- .animateItem(),
+ modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
)
}
@@ -135,23 +130,18 @@ fun ExpandablePlayButtons(
iconStringRes = R.string.fa_heart,
onClick = favoriteOnClick,
iconColor = if (favorite) Color.Red else Color.Unspecified,
- modifier =
- Modifier
- .onFocusChanged(buttonOnFocusChanged)
- .animateItem(),
+ modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
)
}
// More button
item("more") {
ExpandablePlayButton(
- R.string.more,
- Duration.ZERO,
- Icons.Default.MoreVert,
- { moreOnClick.invoke() },
- Modifier
- .onFocusChanged(buttonOnFocusChanged)
- .animateItem(),
+ title = R.string.more,
+ resume = Duration.ZERO,
+ icon = Icons.Default.MoreVert,
+ onClick = { moreOnClick.invoke() },
+ modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
)
}
}
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 f69c018a..869a454d 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
@@ -20,6 +20,7 @@ import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
+import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
@@ -31,6 +32,7 @@ import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
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.util.ApiRequestPager
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState
@@ -44,6 +46,7 @@ abstract class RecommendedViewModel(
val context: Context,
val navigationManager: NavigationManager,
val favoriteWatchManager: FavoriteWatchManager,
+ private val backdropService: BackdropService,
) : ViewModel() {
abstract fun init()
@@ -85,6 +88,12 @@ abstract class RecommendedViewModel(
}
}
+ fun updateBackdrop(item: BaseItem) {
+ viewModelScope.launchIO {
+ backdropService.submit(item)
+ }
+ }
+
abstract fun update(
@StringRes title: Int,
row: HomeRowLoadingState,
@@ -146,8 +155,12 @@ fun RecommendedContent(
onLongClickItem = { position, item ->
moreDialog.makePresent(RowColumnItem(position, item))
},
+ onClickPlay = { _, item ->
+ viewModel.navigationManager.navigateTo(Destination.Playback(item))
+ },
onFocusPosition = onFocusPosition,
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 82eafee9..1272faf5 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
@@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
+import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.SlimItemFields
@@ -56,7 +57,8 @@ class RecommendedMovieViewModel
@Assisted val parentId: UUID,
navigationManager: NavigationManager,
favoriteWatchManager: FavoriteWatchManager,
- ) : RecommendedViewModel(context, navigationManager, favoriteWatchManager) {
+ backdropService: BackdropService,
+ ) : RecommendedViewModel(context, navigationManager, favoriteWatchManager, backdropService) {
@AssistedFactory
interface Factory {
fun create(parentId: UUID): RecommendedMovieViewModel
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 80e8aa72..cb179860 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
@@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
+import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.SlimItemFields
@@ -59,7 +60,8 @@ class RecommendedTvShowViewModel
@Assisted val parentId: UUID,
navigationManager: NavigationManager,
favoriteWatchManager: FavoriteWatchManager,
- ) : RecommendedViewModel(context, navigationManager, favoriteWatchManager) {
+ backdropService: BackdropService,
+ ) : RecommendedViewModel(context, navigationManager, favoriteWatchManager, backdropService) {
@AssistedFactory
interface Factory {
fun create(parentId: UUID): RecommendedTvShowViewModel
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt
index 36cbc904..de79bd5a 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt
@@ -3,6 +3,8 @@ package com.github.damontecres.wholphin.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
@@ -10,7 +12,7 @@ import com.github.damontecres.wholphin.ui.formatDateTime
import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.ui.seasonEpisode
import com.github.damontecres.wholphin.ui.seriesProductionYears
-import com.github.damontecres.wholphin.ui.timeRemaining
+import com.github.damontecres.wholphin.ui.util.LocalClock
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.extensions.ticks
@@ -23,6 +25,7 @@ fun SeriesName(
text = seriesName ?: "",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = modifier,
@@ -55,17 +58,14 @@ fun EpisodeQuickDetails(
dto: BaseItemDto?,
modifier: Modifier = Modifier,
) {
+ val context = LocalContext.current
+ val now = LocalClock.current.now
val details =
- remember(dto) {
+ remember(dto, now) {
buildList {
dto?.seasonEpisode?.let(::add)
dto?.premiereDate?.let { add(formatDateTime(it)) }
- val duration = dto?.runTimeTicks?.ticks
- duration
- ?.roundMinutes
- ?.toString()
- ?.let(::add)
- dto?.timeRemaining?.roundMinutes?.let { add("$it left") }
+ addRuntimeDetails(context, now, dto)
dto?.officialRating?.let(::add)
}
}
@@ -87,11 +87,9 @@ fun SeriesQuickDetails(
remember(dto) {
buildList {
dto?.seriesProductionYears?.let(::add)
- val duration = dto?.runTimeTicks?.ticks
- duration
- ?.roundMinutes
- ?.toString()
- ?.let(::add)
+ dto?.runTimeTicks?.ticks?.roundMinutes?.let {
+ add(it.toString())
+ }
dto?.officialRating?.let(::add)
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt
index 54c2d75c..8fa6f60f 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt
@@ -3,35 +3,21 @@ package com.github.damontecres.wholphin.ui.components
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.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.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
-import com.github.damontecres.wholphin.ui.TimeFormatter
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.isActive
-import java.time.LocalTime
+import com.github.damontecres.wholphin.ui.util.LocalClock
@Composable
fun BoxScope.TimeDisplay(modifier: Modifier = Modifier) {
- var now by remember { mutableStateOf(LocalTime.now()) }
- LaunchedEffect(Unit) {
- while (isActive) {
- now = LocalTime.now()
- delay(1000L)
- }
- }
Text(
- text = TimeFormatter.format(now),
+ text = LocalClock.current.timeString,
fontSize = 18.sp,
color = MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.bodyLarge,
modifier =
modifier
.align(Alignment.TopEnd)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt
index 44e2705d..a557d7f0 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt
@@ -9,7 +9,11 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
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.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.platform.LocalContext
@@ -23,30 +27,41 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.ProvideTextStyle
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
-import com.github.damontecres.wholphin.data.model.ItemPlayback
-import com.github.damontecres.wholphin.data.model.TrackIndex
-import com.github.damontecres.wholphin.data.model.chooseSource
-import com.github.damontecres.wholphin.data.model.chooseStream
+import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.preferences.AppThemeColors
-import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.PreviewTvSpec
+import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.playback.audioStreamCount
import com.github.damontecres.wholphin.ui.playback.embeddedSubtitleCount
import com.github.damontecres.wholphin.ui.playback.externalSubtitlesCount
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.util.languageName
import com.github.damontecres.wholphin.util.profile.Codec
-import org.jellyfin.sdk.model.api.BaseItemDto
-import org.jellyfin.sdk.model.api.MediaStreamType
+import org.jellyfin.sdk.model.api.MediaSourceInfo
+import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.VideoRange
import org.jellyfin.sdk.model.api.VideoRangeType
@Composable
+@NonRestartableComposable
fun VideoStreamDetails(
- preferences: UserPreferences,
- dto: BaseItemDto,
- itemPlayback: ItemPlayback?,
+ chosenStreams: ChosenStreams?,
+ modifier: Modifier = Modifier,
+) = VideoStreamDetails(
+ chosenStreams?.source,
+ chosenStreams?.videoStream,
+ chosenStreams?.audioStream,
+ chosenStreams?.subtitleStream,
+ modifier,
+)
+
+@Composable
+fun VideoStreamDetails(
+ source: MediaSourceInfo?,
+ videoStream: MediaStream?,
+ audioStream: MediaStream?,
+ subtitleStream: MediaStream?,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
@@ -54,17 +69,6 @@ fun VideoStreamDetails(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
- val source = remember(dto, itemPlayback) { chooseSource(dto, itemPlayback) }
-
- val videoStream =
- remember(dto, itemPlayback) {
- chooseStream(
- dto,
- itemPlayback,
- MediaStreamType.VIDEO,
- preferences,
- )
- }
val video =
remember(videoStream) {
videoStream
@@ -81,7 +85,7 @@ fun VideoStreamDetails(
} else {
null
}
- val range = formatVideoRange(context, it.videoRange, it.videoRangeType)
+ val range = formatVideoRange(context, it.videoRange, it.videoRangeType, it.videoDoViTitle)
listOfNotNull(
resName.concatWithSpace(range),
it.codec?.uppercase(),
@@ -92,25 +96,18 @@ fun VideoStreamDetails(
StreamLabel(it)
}
- val audioStream =
- remember(dto, itemPlayback) {
- chooseStream(
- dto,
- itemPlayback,
- MediaStreamType.AUDIO,
- preferences,
- )
- }
val audioCount = remember(source) { source?.audioStreamCount ?: 0 }
val audio =
- if (audioCount == 0 || audioStream == null) {
- stringResource(R.string.none)
- } else {
- listOfNotNull(
- languageName(audioStream.language),
- formatAudioCodec(context, audioStream.codec, audioStream.profile),
- audioStream.channelLayout,
- ).joinToString(" ")
+ remember(audioCount, audioStream) {
+ if (audioCount == 0 || audioStream == null) {
+ context.getString(R.string.none)
+ } else {
+ listOfNotNull(
+ languageName(audioStream.language),
+ formatAudioCodec(context, audioStream.codec, audioStream.profile),
+ audioStream.channelLayout,
+ ).joinToString(" ")
+ }
}
StreamLabel(
text = audio,
@@ -119,30 +116,26 @@ fun VideoStreamDetails(
modifier = Modifier.widthIn(max = 200.dp),
)
- val subtitleStream =
- remember(dto, itemPlayback) {
- chooseStream(
- dto,
- itemPlayback,
- MediaStreamType.SUBTITLE,
- preferences,
- )
- }
val subtitleCount =
remember(source) {
(source?.embeddedSubtitleCount ?: 0) + (source?.externalSubtitlesCount ?: 0)
}
+ var disabled by remember { mutableStateOf(false) }
val subtitle =
- if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) {
- stringResource(R.string.disabled)
- } else if (subtitleCount == 0 || subtitleStream == null) {
- null
- } else {
- listOfNotNull(
- languageName(subtitleStream.language),
- "SDH".takeIf { subtitleStream.isHearingImpaired },
- formatSubtitleCodec(subtitleStream.codec),
- ).joinToString(" ")
+ remember(subtitleCount, subtitleStream) {
+ if (subtitleCount > 0 && subtitleStream == null) {
+ disabled = true
+ context.getString(R.string.disabled)
+ } else if (subtitleCount == 0 || subtitleStream == null) {
+ null
+ } else {
+ disabled = false
+ listOfNotNull(
+ languageName(subtitleStream.language),
+ "SDH".takeIf { subtitleStream.isHearingImpaired },
+ formatSubtitleCodec(subtitleStream.codec),
+ ).joinToString(" ")
+ }
}
subtitle?.let {
StreamLabel(
@@ -150,6 +143,7 @@ fun VideoStreamDetails(
count = subtitleCount,
icon = R.string.fa_closed_captioning,
modifier = Modifier.widthIn(max = 160.dp),
+ disabled = disabled,
)
}
}
@@ -190,6 +184,7 @@ fun StreamLabel(
modifier: Modifier = Modifier,
@StringRes icon: Int? = null,
count: Int = 0,
+ disabled: Boolean = false,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -222,9 +217,10 @@ fun StreamLabel(
overflow = TextOverflow.Ellipsis,
modifier = Modifier,
)
- if (count > 1) {
+ val countToUse = if (disabled) count else count - 1
+ if (countToUse > 0) {
Text(
- text = "(+${count - 1})",
+ text = "(+$countToUse)",
maxLines = 1,
modifier = Modifier,
)
@@ -248,6 +244,10 @@ private fun StreamLabelPreview() {
StreamLabel("HDR")
StreamLabel("H264")
StreamLabel("AC3 5.1", icon = R.string.fa_volume_high, count = 2)
+
+ StreamLabel("PGS", count = 1)
+ StreamLabel("PGS", count = 1, disabled = true)
+ StreamLabel("PGS", count = 2)
}
}
}
@@ -256,6 +256,7 @@ fun formatVideoRange(
context: Context,
videoRange: VideoRange?,
type: VideoRangeType?,
+ doviTitle: String?,
): String? =
when (videoRange) {
VideoRange.UNKNOWN,
@@ -265,23 +266,27 @@ fun formatVideoRange(
}
VideoRange.HDR -> {
- when (type) {
- VideoRangeType.UNKNOWN,
- VideoRangeType.SDR,
- null,
- -> null
+ if (doviTitle.isNotNullOrBlank()) {
+ context.getString(R.string.dolby_vision)
+ } else {
+ when (type) {
+ VideoRangeType.UNKNOWN,
+ VideoRangeType.SDR,
+ null,
+ -> null
- VideoRangeType.HDR10 -> "HDR10"
+ VideoRangeType.HDR10 -> "HDR10"
- VideoRangeType.HDR10_PLUS -> "HDR10+"
+ VideoRangeType.HDR10_PLUS -> "HDR10+"
- VideoRangeType.HLG -> "HLG"
+ VideoRangeType.HLG -> "HLG"
- VideoRangeType.DOVI,
- VideoRangeType.DOVI_WITH_HDR10,
- VideoRangeType.DOVI_WITH_HLG,
- VideoRangeType.DOVI_WITH_SDR,
- -> context.getString(R.string.dolby_vision)
+ VideoRangeType.DOVI,
+ VideoRangeType.DOVI_WITH_HDR10,
+ VideoRangeType.DOVI_WITH_HLG,
+ VideoRangeType.DOVI_WITH_SDR,
+ -> context.getString(R.string.dolby_vision)
+ }
}
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt
index 8142002b..e4e6d350 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt
@@ -113,6 +113,7 @@ data class ViewOptions(
val aspectRatio: AspectRatio = AspectRatio.TALL,
val showDetails: Boolean = false,
val imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
+ val showTitles: Boolean = true,
) {
companion object {
val ViewOptionsColumns =
@@ -165,6 +166,13 @@ data class ViewOptions(
getter = { it.showDetails },
setter = { vo, value -> vo.copy(showDetails = value) },
)
+ val ViewOptionsShowTitles =
+ AppSwitchPreference(
+ title = R.string.show_titles,
+ defaultValue = true,
+ getter = { it.showTitles },
+ setter = { vo, value -> vo.copy(showTitles = value) },
+ )
val ViewOptionsImageType =
AppChoicePreference(
@@ -194,6 +202,7 @@ data class ViewOptions(
ViewOptionsImageType,
ViewOptionsAspectRatio,
ViewOptionsDetailHeader,
+ ViewOptionsShowTitles,
ViewOptionsColumns,
ViewOptionsSpacing,
ViewOptionsContentScale,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt
index cf3e2896..4947fda6 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt
@@ -1,28 +1,35 @@
package com.github.damontecres.wholphin.ui.data
+import android.content.Context
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
-import androidx.tv.material3.ProvideTextStyle
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.byteRateSuffixes
import com.github.damontecres.wholphin.ui.components.ScrollableDialog
+import com.github.damontecres.wholphin.ui.components.formatAudioCodec
+import com.github.damontecres.wholphin.ui.components.formatSubtitleCodec
import com.github.damontecres.wholphin.ui.formatBytes
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.util.languageName
-import org.jellyfin.sdk.model.api.AudioSpatialFormat
import org.jellyfin.sdk.model.api.MediaSourceInfo
+import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
+import org.jellyfin.sdk.model.api.VideoRange
+import org.jellyfin.sdk.model.api.VideoRangeType
import java.util.Locale
data class ItemDetailsDialogInfo(
@@ -37,129 +44,338 @@ fun ItemDetailsDialog(
info: ItemDetailsDialogInfo,
showFilePath: Boolean,
onDismissRequest: () -> Unit,
-) = ScrollableDialog(
- onDismissRequest = onDismissRequest,
) {
- item {
- Text(
- text = info.title,
- style = MaterialTheme.typography.titleLarge,
- )
- }
- if (info.genres.isNotEmpty()) {
+ val context = LocalContext.current
+ // Extract stringResource calls outside of ScrollableDialog's non-composable lambda
+ val pathLabel = stringResource(R.string.path)
+ val fileSizeLabel = stringResource(R.string.file_size)
+ val videoLabel = stringResource(R.string.video)
+ val audioLabel = stringResource(R.string.audio)
+ val subtitleLabel = stringResource(R.string.subtitle)
+ val bitrateLabel = stringResource(R.string.bitrate)
+ val unknown = stringResource(R.string.unknown)
+
+ ScrollableDialog(
+ onDismissRequest = onDismissRequest,
+ width = 720.dp,
+ maxHeight = 480.dp,
+ itemSpacing = 4.dp,
+ ) {
item {
Text(
- text = info.genres.joinToString(", "),
- style = MaterialTheme.typography.titleSmall,
+ text = info.title,
+ style = MaterialTheme.typography.titleLarge,
)
}
- }
- if (info.overview.isNotNullOrBlank()) {
- item {
- Text(
- text = info.overview,
- style = MaterialTheme.typography.bodyLarge,
- )
+ if (info.genres.isNotEmpty()) {
+ item {
+ Text(
+ text = info.genres.joinToString(", "),
+ style = MaterialTheme.typography.titleSmall,
+ )
+ }
}
- }
- if (info.files.isNotEmpty()) {
- item {
- Spacer(Modifier.height(12.dp))
+ if (info.overview.isNotNullOrBlank()) {
+ item {
+ Text(
+ text = info.overview,
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ }
}
- }
- items(info.files) { file ->
- ProvideTextStyle(MaterialTheme.typography.bodyMedium) {
- MediaSourceInfo(file, showFilePath, Modifier.padding(top = 12.dp))
+
+ // Show detailed media information for the selected source (first one if multiple)
+ info.files.forEachIndexed { index, source ->
+ source.mediaStreams?.letNotEmpty { mediaStreams ->
+ item {
+ Spacer(Modifier.height(8.dp))
+ }
+
+ // General file information
+ item {
+ val containerLabel = stringResource(R.string.container)
+ MediaInfoSection(
+ title = stringResource(R.string.general),
+ items =
+ buildList {
+ source.container?.let { add(containerLabel to it) }
+ if (showFilePath) {
+ source.path?.let { add(pathLabel to it) }
+ add("ID" to (source.id ?: unknown))
+ }
+ source.size?.let {
+ add(fileSizeLabel to formatBytes(it))
+ }
+ source.bitrate?.let {
+ add(
+ bitrateLabel to formatBytes(it, byteRateSuffixes),
+ )
+ }
+ },
+ )
+ }
+
+ // Video streams
+ items(mediaStreams.filter { it.type == MediaStreamType.VIDEO }) { stream ->
+ MediaInfoSection(
+ title = videoLabel,
+ items = buildVideoStreamInfo(context, stream),
+ )
+ }
+
+ // Audio streams - display multiple per row
+ items(
+ mediaStreams
+ .filter { it.type == MediaStreamType.AUDIO }
+ .chunked(3),
+ ) { streamGroup ->
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ streamGroup.forEach { stream ->
+ MediaInfoSection(
+ title = audioLabel,
+ items = buildAudioStreamInfo(context, stream),
+ modifier = Modifier.weight(1f),
+ )
+ }
+ // Fill remaining space if less than 3 items
+ repeat(3 - streamGroup.size) {
+ Spacer(modifier = Modifier.weight(1f))
+ }
+ }
+ }
+
+ // Subtitle streams - display multiple per row
+ items(
+ mediaStreams
+ .filter { it.type == MediaStreamType.SUBTITLE }
+ .chunked(3),
+ ) { streamGroup ->
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ streamGroup.forEach { stream ->
+ MediaInfoSection(
+ title = subtitleLabel,
+ items = buildSubtitleStreamInfo(context, stream),
+ modifier = Modifier.weight(1f),
+ )
+ }
+ // Fill remaining space if less than 3 items
+ repeat(3 - streamGroup.size) {
+ Spacer(modifier = Modifier.weight(1f))
+ }
+ }
+ }
+ if (index != info.files.lastIndex) {
+ item {
+ Spacer(Modifier.height(8.dp))
+ }
+ }
+ }
}
}
}
@Composable
-fun MediaSourceInfo(
- source: MediaSourceInfo,
- showFilePath: Boolean,
+private fun MediaInfoSection(
+ title: String,
+ items: List>,
modifier: Modifier = Modifier,
) {
Column(
- verticalArrangement = Arrangement.spacedBy(4.dp),
- modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ modifier = modifier.padding(vertical = 4.dp),
) {
Text(
- text = stringResource(R.string.name) + ": ${source.name}",
+ text = title,
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.primary,
)
- Text(
- text = "ID: ${source.id}",
- )
- if (showFilePath) {
- Text(
- text = stringResource(R.string.path) + ": ${source.path}",
- )
- }
- source.size?.let { size ->
- Text(
- text = stringResource(R.string.file_size) + ": ${formatBytes(size)}",
- )
- }
- source.bitrate?.let { bitrate ->
- Text(
- text =
- stringResource(R.string.bitrate) + ": ${
- formatBytes(
- bitrate,
- byteRateSuffixes,
- )
- }",
- )
- }
- source.mediaStreams?.letNotEmpty { streams ->
- streams.filter { it.type == MediaStreamType.VIDEO }.forEach { stream ->
- val data =
- buildList {
- if (stream.width != null && stream.height != null) {
- add("${stream.width}x${stream.height}")
- }
- stream.averageFrameRate?.let {
- add(String.format(Locale.getDefault(), "%.3f", it) + " fps")
- }
- stream.bitRate?.let { add(formatBytes(it, byteRateSuffixes)) }
- stream.codec?.let(::add)
- stream.profile?.let(::add)
- }
+ items.forEach { (label, value) ->
+ Row(
+ modifier = Modifier.padding(start = 12.dp),
+ ) {
Text(
- text = stringResource(R.string.video) + ": " + data.joinToString(" - "),
+ text = "$label: ",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
)
- }
-
- streams.filter { it.type == MediaStreamType.AUDIO }.forEachIndexed { index, stream ->
- val data =
- buildList {
- stream.language?.let { add(languageName(it)) }
- stream.codec?.let(::add)
- stream.channelLayout?.let(::add)
- stream.bitRate?.let { add(formatBytes(it, byteRateSuffixes)) }
- if (stream.audioSpatialFormat != AudioSpatialFormat.NONE) add(stream.audioSpatialFormat.serialName)
- if (stream.isDefault) add(stringResource(R.string.default_track))
- }
Text(
- text = stringResource(R.string.audio) + " ${index + 1}: " + data.joinToString(" - "),
- )
- }
-
- streams.filter { it.type == MediaStreamType.SUBTITLE }.forEachIndexed { index, stream ->
- val data =
- buildList {
- stream.language?.let { add(languageName(it)) }
- stream.codec?.let(::add)
- if (stream.isDefault) add(stringResource(R.string.default_track))
- if (stream.isForced) add(stringResource(R.string.forced_track))
- if (stream.isExternal) add(stringResource(R.string.external_track))
- }
- Text(
- text =
- stringResource(R.string.subtitle) + " ${index + 1}: " +
- data.joinToString(" - "),
+ text = value,
+ style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
+
+private fun buildVideoStreamInfo(
+ context: Context,
+ stream: MediaStream,
+): List> =
+ buildList {
+ val titleLabel = context.getString(R.string.title)
+ val codecLabel = context.getString(R.string.codec)
+ val avcLabel = context.getString(R.string.avc)
+ val profileLabel = context.getString(R.string.profile)
+ val levelLabel = context.getString(R.string.level)
+ val resolutionLabel = context.getString(R.string.resolution)
+ val aspectRatioLabel = context.getString(R.string.aspect_ratio)
+ val anamorphicLabel = context.getString(R.string.anamorphic)
+ val interlacedLabel = context.getString(R.string.interlaced)
+ val framerateLabel = context.getString(R.string.framerate)
+ val bitrateLabel = context.getString(R.string.bitrate)
+ val bitDepthLabel = context.getString(R.string.bit_depth)
+ val videoRangeLabel = context.getString(R.string.video_range)
+ val videoRangeTypeLabel = context.getString(R.string.video_range_type)
+ val colorSpaceLabel = context.getString(R.string.color_space)
+ val colorTransferLabel = context.getString(R.string.color_transfer)
+ val colorPrimariesLabel = context.getString(R.string.color_primaries)
+ val pixelFormatLabel = context.getString(R.string.pixel_format)
+ val refFramesLabel = context.getString(R.string.ref_frames)
+ val nalLabel = context.getString(R.string.nal)
+ val yesStr = context.getString(R.string.yes)
+ val noStr = context.getString(R.string.no)
+ val sdrStr = context.getString(R.string.sdr)
+ val hdrStr = context.getString(R.string.hdr)
+ val hdr10Str = context.getString(R.string.hdr10)
+ val hdr10PlusStr = context.getString(R.string.hdr10_plus)
+ val hlgStr = context.getString(R.string.hlg)
+ val bitUnit = context.getString(R.string.bit_unit)
+
+ stream.title?.let { add(titleLabel to it) }
+ stream.codec?.let { add(codecLabel to it.uppercase()) }
+ stream.isAvc?.let { add(avcLabel to if (it) yesStr else noStr) }
+ stream.profile?.let { add(profileLabel to it) }
+ stream.level?.let { add(levelLabel to it.toString()) }
+ if (stream.width != null && stream.height != null) {
+ add(resolutionLabel to "${stream.width}x${stream.height}")
+ }
+ if (stream.width != null && stream.height != null) {
+ val aspectRatio = calculateAspectRatio(stream.width!!, stream.height!!)
+ add(aspectRatioLabel to aspectRatio)
+ }
+ stream.isAnamorphic?.let { add(anamorphicLabel to if (it) yesStr else noStr) }
+ stream.isInterlaced?.let { add(interlacedLabel to if (it) yesStr else noStr) }
+ stream.averageFrameRate?.let {
+ add(framerateLabel to String.format(Locale.getDefault(), "%.3f", it))
+ }
+ stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) }
+ stream.bitDepth?.let { add(bitDepthLabel to "$it $bitUnit") }
+ stream.videoRange?.let {
+ val rangeStr =
+ when (it) {
+ VideoRange.SDR -> sdrStr
+ VideoRange.HDR -> hdrStr
+ VideoRange.UNKNOWN -> null
+ else -> null
+ }
+ rangeStr?.let { add(videoRangeLabel to it) }
+ }
+ stream.videoRangeType?.let {
+ val rangeTypeStr =
+ when (it) {
+ VideoRangeType.SDR -> sdrStr
+
+ VideoRangeType.HDR10 -> hdr10Str
+
+ VideoRangeType.HDR10_PLUS -> hdr10PlusStr
+
+ VideoRangeType.HLG -> hlgStr
+
+ VideoRangeType.DOVI,
+ VideoRangeType.DOVI_WITH_HDR10,
+ VideoRangeType.DOVI_WITH_HLG,
+ VideoRangeType.DOVI_WITH_SDR,
+ -> context.getString(R.string.dolby_vision)
+
+ VideoRangeType.UNKNOWN -> null
+
+ else -> null
+ }
+ rangeTypeStr?.let { add(videoRangeTypeLabel to it) }
+ }
+ stream.colorSpace?.let { add(colorSpaceLabel to it) }
+ stream.colorTransfer?.let { add(colorTransferLabel to it) }
+ stream.colorPrimaries?.let { add(colorPrimariesLabel to it) }
+ stream.pixelFormat?.let { add(pixelFormatLabel to it) }
+ stream.refFrames?.let { add(refFramesLabel to it.toString()) }
+ stream.nalLengthSize?.let { add(nalLabel to it.toString()) }
+ }
+
+private fun buildAudioStreamInfo(
+ context: Context,
+ stream: MediaStream,
+): List> =
+ buildList {
+ val titleLabel = context.getString(R.string.title)
+ val languageLabel = context.getString(R.string.language)
+ val codecLabel = context.getString(R.string.codec)
+ val layoutLabel = context.getString(R.string.layout)
+ val channelsLabel = context.getString(R.string.channels)
+ val bitrateLabel = context.getString(R.string.bitrate)
+ val sampleRateLabel = context.getString(R.string.sample_rate)
+ val defaultLabel = context.getString(R.string.default_track)
+ val externalLabel = context.getString(R.string.external_track)
+ val yesStr = context.getString(R.string.yes)
+ val noStr = context.getString(R.string.no)
+ val sampleRateUnit = context.getString(R.string.sample_rate_unit)
+
+ stream.title?.let { add(titleLabel to it) }
+ stream.language?.let { add(languageLabel to languageName(it)) }
+ stream.codec?.let {
+ val formattedCodec = formatAudioCodec(context, it, stream.profile) ?: it.uppercase()
+ add(codecLabel to formattedCodec)
+ }
+ stream.channelLayout?.let { add(layoutLabel to it) }
+ stream.channels?.let { add(channelsLabel to it.toString()) }
+ stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) }
+ stream.sampleRate?.let { add(sampleRateLabel to "$it $sampleRateUnit") }
+ stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) }
+ }
+
+private fun buildSubtitleStreamInfo(
+ context: Context,
+ stream: MediaStream,
+): List> =
+ buildList {
+ val titleLabel = context.getString(R.string.title)
+ val languageLabel = context.getString(R.string.language)
+ val codecLabel = context.getString(R.string.codec)
+ val avcLabel = context.getString(R.string.avc)
+ val defaultLabel = context.getString(R.string.default_track)
+ val forcedLabel = context.getString(R.string.forced_track)
+ val externalLabel = context.getString(R.string.external_track)
+ val yesStr = context.getString(R.string.yes)
+ val noStr = context.getString(R.string.no)
+
+ stream.title?.let { add(titleLabel to it) }
+ stream.language?.let { add(languageLabel to languageName(it)) }
+ stream.codec?.let {
+ val formattedCodec = formatSubtitleCodec(it) ?: it.uppercase()
+ add(codecLabel to formattedCodec)
+ }
+ stream.isAvc?.let { add(avcLabel to if (it) yesStr else noStr) }
+ stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) }
+ stream.isForced?.let { add(forcedLabel to if (it) yesStr else noStr) }
+ stream.isExternal?.let { add(externalLabel to if (it) yesStr else noStr) }
+ }
+
+private fun calculateAspectRatio(
+ width: Int,
+ height: Int,
+): String {
+ val gcd = gcd(width, height)
+ val w = width / gcd
+ val h = height / gcd
+ return "$w:$h"
+}
+
+private fun gcd(
+ a: Int,
+ b: Int,
+): Int = if (b == 0) a else gcd(b, a % b)
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 633ba5bc..ba74641a 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
@@ -22,6 +22,7 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
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.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -33,11 +34,14 @@ 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.draw.alpha
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
@@ -49,6 +53,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
+import androidx.tv.material3.Button
+import androidx.tv.material3.ButtonDefaults
import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
@@ -57,8 +63,6 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.cards.GridCard
-import com.github.damontecres.wholphin.ui.components.Button
-import com.github.damontecres.wholphin.ui.components.TextButton
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.playback.isBackwardButton
import com.github.damontecres.wholphin.ui.playback.isForwardButton
@@ -69,15 +73,23 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
+import java.util.UUID
private const val DEBUG = false
+interface CardGridItem {
+ val id: UUID
+ val playable: Boolean
+ val sortName: String
+}
+
@OptIn(ExperimentalFoundationApi::class)
@Composable
-fun CardGrid(
- pager: List,
- onClickItem: (Int, BaseItem) -> Unit,
- onLongClickItem: (Int, BaseItem) -> Unit,
+fun CardGrid(
+ pager: List,
+ onClickItem: (Int, T) -> Unit,
+ onLongClickItem: (Int, T) -> Unit,
+ onClickPlay: (Int, T) -> Unit,
letterPosition: suspend (Char) -> Int,
gridFocusRequester: FocusRequester,
showJumpButtons: Boolean,
@@ -86,13 +98,13 @@ fun CardGrid(
initialPosition: Int = 0,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
cardContent: @Composable (
- item: BaseItem?,
+ item: T?,
onClick: () -> Unit,
onLongClick: () -> Unit,
mod: Modifier,
) -> Unit = { item, onClick, onLongClick, mod ->
GridCard(
- item = item,
+ item = item as BaseItem?,
onClick = onClick,
onLongClick = onLongClick,
imageContentScale = ContentScale.FillBounds,
@@ -213,7 +225,11 @@ fun CardGrid(
jumpToTop()
return@onKeyEvent true
} else if (isPlayKeyUp(it)) {
- // TODO play the focused item
+ val item = pager.getOrNull(focusedIndex)
+ if (item?.playable == true) {
+ Timber.v("Clicked play on ${item.id}")
+ onClickPlay.invoke(focusedIndex, item)
+ }
return@onKeyEvent true
} else if (useJumpRemoteButtons && isForwardButton(it)) {
jump(jump1)
@@ -341,7 +357,6 @@ fun CardGrid(
remember(focusedIndex) {
pager
.getOrNull(focusedIndex)
- ?.data
?.sortName
?.first()
?.uppercaseChar()
@@ -360,7 +375,11 @@ fun CardGrid(
AlphabetButtons(
letters = letters,
currentLetter = currentLetter,
- modifier = Modifier.align(Alignment.CenterVertically),
+ modifier =
+ Modifier
+ .align(Alignment.CenterVertically)
+ .padding(end = 16.dp),
+ // Add end padding to push away from edge
letterClicked = { letter ->
scope.launch(ExceptionHandler()) {
val jumpPosition =
@@ -369,6 +388,7 @@ fun CardGrid(
}
Timber.d("Alphabet jump to $jumpPosition")
if (jumpPosition >= 0) {
+ pager.getOrNull(jumpPosition)
gridState.scrollToItem(jumpPosition)
focusOn(jumpPosition)
alphabetFocus = true
@@ -422,7 +442,6 @@ fun AlphabetButtons(
letterClicked: (Char) -> Unit,
modifier: Modifier = Modifier,
) {
- val context = LocalContext.current
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
@@ -434,51 +453,97 @@ fun AlphabetButtons(
listState.layoutInfo.visibleItemsInfo
.lastOrNull()
?.index ?: -1
- if (index < firstVisibleItemIndex || index > lastVisibleItemIndex) {
+ if (index !in firstVisibleItemIndex..lastVisibleItemIndex) {
listState.animateScrollToItem(index)
}
}
}
+ // Focus & interaction states for each letter button
val focusRequesters = remember { List(letters.length) { FocusRequester() } }
+ val interactionSources = remember { List(letters.length) { MutableInteractionSource() } }
+
+ // Track if the entire alphabet picker component has focus
+ var alphabetPickerFocused by remember { mutableStateOf(false) }
+
LazyColumn(
- contentPadding = PaddingValues(4.dp),
+ contentPadding = PaddingValues(vertical = 1.1.dp, horizontal = 2.dp),
+ verticalArrangement = Arrangement.spacedBy(1.1.dp),
state = listState,
modifier =
- modifier.focusProperties {
- onEnter = {
- focusRequesters[index.coerceIn(0, letters.length - 1)].tryRequestFocus()
- }
- },
+ modifier
+ .onFocusChanged { focusState ->
+ alphabetPickerFocused = focusState.hasFocus
+ }.focusProperties {
+ onEnter = {
+ focusRequesters[index.coerceIn(0, letters.length - 1)].tryRequestFocus()
+ }
+ },
) {
items(
letters.length,
key = { letters[it] },
) { index ->
- val interactionSource = remember { MutableInteractionSource() }
+ val interactionSource = interactionSources[index]
val focused by interactionSource.collectIsFocusedAsState()
- TextButton(
+
+ val isCurrentLetter = letters[index] == currentLetter
+ // Apply alpha to individual items, but keep selected letter fully visible when picker is unfocused
+ val itemAlpha =
+ when {
+ isCurrentLetter && !alphabetPickerFocused -> 1f
+ alphabetPickerFocused -> .85f
+ else -> .25f
+ }
+
+ // Only show circle background for the current letter (or when focused)
+ // Wrap in Box with clipping to prevent focus indicator from overflowing
+ Box(
modifier =
Modifier
- .size(24.dp)
- .focusRequester(focusRequesters[index]),
- contentPadding = PaddingValues(2.dp),
- interactionSource = interactionSource,
- onClick = {
- letterClicked.invoke(letters[index])
- },
+ .size(14.dp)
+ .clip(CircleShape)
+ .alpha(itemAlpha),
) {
- val color =
- if (!focused && letters[index] == currentLetter) {
- MaterialTheme.colorScheme.tertiary
- } else {
- LocalContentColor.current
- }
- Text(
- text = letters[index].toString(),
- color = color,
- textAlign = TextAlign.Center,
- modifier = Modifier.fillMaxWidth(),
- )
+ Button(
+ modifier =
+ Modifier
+ .size(14.dp)
+ .focusRequester(focusRequesters[index]),
+ contentPadding = PaddingValues(0.dp), // No padding to maximize text space
+ interactionSource = interactionSource,
+ onClick = {
+ letterClicked.invoke(letters[index])
+ },
+ colors =
+ if (isCurrentLetter || focused) {
+ // Use default button colors for current letter or focused
+ ButtonDefaults.colors()
+ } else {
+ // Transparent background for non-current letters (no circle)
+ ButtonDefaults.colors(
+ containerColor = Color.Transparent,
+ contentColor = MaterialTheme.colorScheme.onSurface,
+ focusedContainerColor = MaterialTheme.colorScheme.primaryContainer,
+ focusedContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ )
+ },
+ ) {
+ // Use border color for selected letter when focused, tertiary for unfocused-selected
+ val color =
+ when {
+ isCurrentLetter && focused -> MaterialTheme.colorScheme.border
+ isCurrentLetter -> MaterialTheme.colorScheme.tertiary
+ focused -> LocalContentColor.current
+ else -> MaterialTheme.colorScheme.onSurface
+ }
+ Text(
+ text = letters[index].toString(),
+ color = color,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
}
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt
index a006b168..2b69c105 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt
@@ -31,6 +31,7 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences
+import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
import com.github.damontecres.wholphin.ui.components.ErrorMessage
@@ -59,6 +60,7 @@ class LiveTvCollectionViewModel
val serverRepository: ServerRepository,
val navigationManager: NavigationManager,
val rememberTabManager: RememberTabManager,
+ val backdropService: BackdropService,
) : ViewModel(),
RememberTabManager by rememberTabManager {
val recordingFolders = MutableLiveData>()
@@ -106,6 +108,7 @@ fun CollectionFolderLiveTv(
LaunchedEffect(selectedTabIndex) {
logTab("livetv", selectedTabIndex)
viewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
+ viewModel.backdropService.clearBackdrop()
}
val onClickItem = { position: Int, item: BaseItem ->
viewModel.navigationManager.navigateTo(item.destination())
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt
index 1fad983d..798683aa 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt
@@ -65,6 +65,7 @@ fun CollectionFolderMovie(
LaunchedEffect(selectedTabIndex) {
logTab("movie", selectedTabIndex)
preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
+ preferencesViewModel.backdropService.clearBackdrop()
}
var showHeader by rememberSaveable { mutableStateOf(true) }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt
index e3fae08e..b32189d4 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt
@@ -64,6 +64,7 @@ fun CollectionFolderTv(
LaunchedEffect(selectedTabIndex) {
logTab("tv", selectedTabIndex)
preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
+ preferencesViewModel.backdropService.clearBackdrop()
}
val onClickItem = { item: BaseItem ->
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 5f771ab1..b65f8624 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
@@ -36,6 +36,7 @@ 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
@@ -58,6 +59,9 @@ 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())
@@ -220,6 +224,7 @@ fun DebugPage(
listOf(
"Version Name: ${pkgInfo.versionName}",
"Version Code: ${pkgInfo.versionCodeLong}",
+ "ClientInfo: ${viewModel.clientInfo}",
"Build type: ${BuildConfig.BUILD_TYPE}",
"Debug enabled: ${BuildConfig.DEBUG}",
"ABIs: ${Build.SUPPORTED_ABIS.toList()}",
@@ -233,6 +238,32 @@ fun DebugPage(
}
}
}
+ item {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(
+ text = "Device Information",
+ style = MaterialTheme.typography.displaySmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+
+ listOf(
+ "DeviceInfo: ${viewModel.deviceInfo}",
+ "Manufacturer: ${Build.MANUFACTURER}",
+ "Model: ${Build.MODEL}",
+ "Display Modes:",
+ *viewModel.refreshRateService.displayModes,
+ ).forEach {
+ Text(
+ text = it.toString(),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ }
+ }
item {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt
index 23daa573..c920fb5b 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt
@@ -4,6 +4,7 @@ import android.content.Context
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.ArrowForward
+import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.ui.graphics.Color
@@ -43,6 +44,7 @@ data class MoreDialogActions(
* @param navigateTo a function to trigger a navigation
* @param onChooseVersion callback to pick a version of the item
* @param onChooseTracks callback to pick a track for the given type of the item
+ * @param onShowOverview callback to show overview dialog with media information
*/
fun buildMoreDialogItems(
context: Context,
@@ -54,6 +56,7 @@ fun buildMoreDialogItems(
actions: MoreDialogActions,
onChooseVersion: () -> Unit,
onChooseTracks: (MediaStreamType) -> Unit,
+ onShowOverview: () -> Unit,
): List =
buildList {
add(
@@ -159,6 +162,16 @@ fun buildMoreDialogItems(
},
)
}
+ if (item.data.mediaSources?.isNotEmpty() == true) {
+ add(
+ DialogItem(
+ context.getString(R.string.media_information),
+ Icons.Default.Info,
+ ) {
+ onShowOverview.invoke()
+ },
+ )
+ }
add(
DialogItem(
context.getString(R.string.play_with_transcoding),
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt
index dff167c1..5157f7d7 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt
@@ -82,6 +82,7 @@ fun FavoritesPage(
NavDrawerItem.Favorites.id,
selectedTabIndex,
)
+ preferencesViewModel.backdropService.clearBackdrop()
}
var showHeader by rememberSaveable { mutableStateOf(true) }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt
index 402ee077..150228a5 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt
@@ -50,10 +50,11 @@ import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.DefaultItemFields
+import com.github.damontecres.wholphin.ui.TimeFormatter
import com.github.damontecres.wholphin.ui.cards.ItemCardImage
-import com.github.damontecres.wholphin.ui.components.DelayedDetailsBackdropImage
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
@@ -64,9 +65,11 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.ifElse
+import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.ui.tryRequestFocus
+import com.github.damontecres.wholphin.ui.util.LocalClock
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.GetPlaylistItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
@@ -88,6 +91,7 @@ class PlaylistViewModel
constructor(
api: ApiClient,
val navigationManager: NavigationManager,
+ private val backdropService: BackdropService,
) : ItemViewModel(api) {
val loading = MutableLiveData(LoadingState.Pending)
val items = MutableLiveData>(listOf())
@@ -111,6 +115,12 @@ class PlaylistViewModel
}
}
}
+
+ fun updateBackdrop(item: BaseItem) {
+ viewModelScope.launchIO {
+ backdropService.submit(item)
+ }
+ }
}
@Composable
@@ -146,6 +156,7 @@ fun PlaylistDetails(
playlist = it,
items = items,
focusRequester = focusRequester,
+ onChangeBackdrop = viewModel::updateBackdrop,
onClickIndex = { index, _ ->
viewModel.navigationManager.navigateTo(
Destination.PlaybackList(
@@ -212,6 +223,7 @@ fun PlaylistDetailsContent(
onClickIndex: (Int, BaseItem) -> Unit,
onLongClickIndex: (Int, BaseItem) -> Unit,
onClickPlay: (shuffle: Boolean) -> Unit,
+ onChangeBackdrop: (BaseItem) -> Unit,
modifier: Modifier = Modifier,
focusRequester: FocusRequester = remember { FocusRequester() },
) {
@@ -219,13 +231,15 @@ fun PlaylistDetailsContent(
var focusedIndex by remember { mutableIntStateOf(savedIndex) }
val focus = remember { FocusRequester() }
val focusedItem = items.getOrNull(focusedIndex)
+ LaunchedEffect(focusedItem) {
+ focusedItem?.let(onChangeBackdrop)
+ }
val playButtonFocusRequester = remember { FocusRequester() }
Box(
modifier = modifier,
) {
- DelayedDetailsBackdropImage(focusedItem)
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier =
@@ -395,10 +409,22 @@ fun PlaylistItem(
)
},
trailingContent = {
- item?.data?.runTimeTicks?.ticks?.roundMinutes?.let {
- Text(
- text = it.toString(),
- )
+ item?.data?.runTimeTicks?.ticks?.roundMinutes?.let { duration ->
+ val now = LocalClock.current.now
+ val endTimeStr =
+ remember(item, now) {
+ val endTime = now.toLocalTime().plusSeconds(duration.inWholeSeconds)
+ TimeFormatter.format(endTime)
+ }
+ Column {
+ Text(
+ text = duration.toString(),
+ )
+ Text(
+ text = stringResource(R.string.ends_at, endTimeStr),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
}
},
leadingContent = {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistList.kt
index 65b3e43f..f94f7381 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistList.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistList.kt
@@ -9,8 +9,8 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.CircularProgressIndicator
@@ -20,6 +20,7 @@ import androidx.compose.runtime.LaunchedEffect
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
@@ -133,7 +134,7 @@ fun PlaylistList(
properties = DialogProperties(usePlatformDefaultWidth = false),
elevation = 10.dp,
) {
- val playlistName = rememberTextFieldState()
+ var playlistName by rememberSaveable { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Column(
@@ -148,7 +149,8 @@ fun PlaylistList(
text = stringResource(R.string.name),
)
EditTextBox(
- state = playlistName,
+ value = playlistName,
+ onValueChange = { playlistName = it },
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.Words,
@@ -156,10 +158,17 @@ fun PlaylistList(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Done,
),
- onKeyboardAction = {
- showCreateDialog = false
- onCreatePlaylist.invoke(playlistName.text.toString())
- },
+ keyboardActions =
+ KeyboardActions(
+ onDone = {
+ showCreateDialog = false
+ onCreatePlaylist.invoke(playlistName)
+ },
+ ),
+ // onKeyboardAction = {
+// showCreateDialog = false
+// onCreatePlaylist.invoke(playlistName.text.toString())
+// },
modifier =
Modifier
.focusRequester(focusRequester)
@@ -168,9 +177,9 @@ fun PlaylistList(
Button(
onClick = {
showCreateDialog = false
- onCreatePlaylist.invoke(playlistName.text.toString())
+ onCreatePlaylist.invoke(playlistName)
},
- enabled = playlistName.text.isNotNullOrBlank(),
+ enabled = playlistName.isNotNullOrBlank(),
modifier = Modifier,
) {
Text(text = stringResource(R.string.submit))
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 cd83bde4..bca08e7b 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt
@@ -30,9 +30,7 @@ import androidx.lifecycle.compose.LifecycleStartEffect
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.model.BaseItem
-import com.github.damontecres.wholphin.data.model.chooseSource
import com.github.damontecres.wholphin.preferences.UserPreferences
-import com.github.damontecres.wholphin.ui.components.DetailsBackdropImage
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage
@@ -57,6 +55,7 @@ import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUID
+import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID
import kotlin.time.Duration
@@ -157,7 +156,7 @@ fun EpisodeDetails(
watched = ep.data.userData?.played ?: false,
favorite = ep.data.userData?.isFavorite ?: false,
seriesId = ep.data.seriesId,
- sourceId = chosenStreams?.sourceId,
+ sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
actions = moreActions,
onChooseVersion = {
chooseVersion =
@@ -174,23 +173,36 @@ fun EpisodeDetails(
moreDialog = null
},
onChooseTracks = { type ->
- chooseSource(
- ep.data,
- chosenStreams?.itemPlayback,
- )?.let { source ->
- chooseVersion =
- chooseStream(
- context = context,
- streams = source.mediaStreams.orEmpty(),
- type = type,
- onClick = { trackIndex ->
- viewModel.saveTrackSelection(
- ep,
- chosenStreams?.itemPlayback,
- trackIndex,
- type,
- )
- },
+ viewModel.streamChoiceService
+ .chooseSource(
+ ep.data,
+ chosenStreams?.itemPlayback,
+ )?.let { source ->
+ chooseVersion =
+ chooseStream(
+ context = context,
+ streams = source.mediaStreams.orEmpty(),
+ type = type,
+ onClick = { trackIndex ->
+ viewModel.saveTrackSelection(
+ ep,
+ chosenStreams?.itemPlayback,
+ trackIndex,
+ type,
+ )
+ },
+ )
+ }
+ },
+ onShowOverview = {
+ val source = chosenStreams?.source ?: ep.data.mediaSources?.firstOrNull()
+ if (source != null) {
+ overviewDialog =
+ ItemDetailsDialogInfo(
+ title = ep.name ?: context.getString(R.string.unknown),
+ overview = ep.data.overview,
+ genres = ep.data.genres.orEmpty(),
+ files = listOf(source),
)
}
},
@@ -283,7 +295,6 @@ fun EpisodeDetailsContent(
focusRequesters.getOrNull(position)?.tryRequestFocus()
}
Box(modifier = modifier) {
- DetailsBackdropImage(ep)
LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp),
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt
index 126f9f30..d3c77b67 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt
@@ -58,9 +58,7 @@ fun EpisodeDetailsHeader(
EpisodeQuickDetails(dto)
VideoStreamDetails(
- preferences = preferences,
- dto = dto,
- itemPlayback = chosenStreams?.itemPlayback,
+ chosenStreams = chosenStreams,
modifier = Modifier.padding(bottom = padding),
)
dto.taglines?.firstOrNull()?.let { tagline ->
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt
index b07e4b5c..584d5510 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt
@@ -10,9 +10,12 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.preferences.ThemeSongVolume
+import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager
+import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.ThemeSongPlayer
+import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain
@@ -44,8 +47,11 @@ class EpisodeViewModel
private val navigationManager: NavigationManager,
val serverRepository: ServerRepository,
val itemPlaybackRepository: ItemPlaybackRepository,
+ val streamChoiceService: StreamChoiceService,
private val themeSongPlayer: ThemeSongPlayer,
private val favoriteWatchManager: FavoriteWatchManager,
+ private val userPreferencesService: UserPreferencesService,
+ private val backdropService: BackdropService,
@Assisted val itemId: UUID,
) : ViewModel() {
@AssistedFactory
@@ -85,12 +91,14 @@ class EpisodeViewModel
"Error fetching movie",
),
) {
+ val prefs = userPreferencesService.getCurrent()
val item = fetchAndSetItem().await()
- val result = itemPlaybackRepository.getSelectedTracks(item.id, item)
+ val result = itemPlaybackRepository.getSelectedTracks(item.id, item, prefs)
withContext(Dispatchers.Main) {
this@EpisodeViewModel.item.value = item
chosenStreams.value = result
loading.value = LoadingState.Success
+ backdropService.submit(item)
}
}
@@ -115,10 +123,12 @@ class EpisodeViewModel
sourceId: UUID,
) {
viewModelScope.launchIO {
+ val prefs = userPreferencesService.getCurrent()
+ val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
val chosen =
result?.let {
- itemPlaybackRepository.getChosenItemFromPlayback(item, result)
+ itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
}
withContext(Dispatchers.Main) {
chosenStreams.value = chosen
@@ -133,6 +143,8 @@ class EpisodeViewModel
type: MediaStreamType,
) {
viewModelScope.launchIO {
+ val prefs = userPreferencesService.getCurrent()
+ val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result =
itemPlaybackRepository.saveTrackSelection(
item = item,
@@ -142,7 +154,7 @@ class EpisodeViewModel
)
val chosen =
result?.let {
- itemPlaybackRepository.getChosenItemFromPlayback(item, result)
+ itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
}
withContext(Dispatchers.Main) {
chosenStreams.value = chosen
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt
index 7011975f..c27cde4e 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt
@@ -10,8 +10,10 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -20,60 +22,99 @@ import androidx.tv.material3.Text
import androidx.tv.material3.contentColorFor
import androidx.tv.material3.surfaceColorAtElevation
import coil3.compose.AsyncImage
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.ui.FontAwesome
+import java.time.LocalDateTime
@Composable
fun Program(
+ guideStart: LocalDateTime,
program: TvProgram,
focused: Boolean,
+ colorCode: Boolean,
modifier: Modifier = Modifier,
) {
val background =
if (focused) {
MaterialTheme.colorScheme.inverseSurface
- } else {
+ } else if (colorCode) {
program.category?.color
?: MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)
+ } else {
+ MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)
}
val textColor = MaterialTheme.colorScheme.contentColorFor(background)
+ val startedBeforeGuide = program.start.isBefore(guideStart)
+ val shape =
+ remember(startedBeforeGuide) {
+ val cornerSize = 4.dp
+ if (startedBeforeGuide) {
+ RoundedCornerShape(
+ topEnd = cornerSize,
+ bottomEnd = cornerSize,
+ topStart = 0.dp,
+ bottomStart = 0.dp,
+ )
+ } else {
+ RoundedCornerShape(cornerSize)
+ }
+ }
+ val title = program.name ?: program.id.toString()
Box(
modifier =
modifier
.padding(2.dp)
.fillMaxSize()
.background(
- background,
- shape = RoundedCornerShape(4.dp),
+ color = background,
+ shape = shape,
),
) {
- Column(
- verticalArrangement = Arrangement.spacedBy(4.dp),
- modifier =
- Modifier
- .fillMaxSize()
- .padding(4.dp),
+ Row(
+ modifier = Modifier.fillMaxSize(),
) {
- Text(
- text = program.name ?: program.id.toString(),
- color = textColor,
- fontSize = 16.sp,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier,
- )
- listOfNotNull(
- program.seasonEpisode?.let { "S${it.season} E${it.episode}" },
- program.subtitle,
- ).joinToString(" - ")
- .ifBlank { null }
- ?.let {
- Text(
- text = it,
- color = textColor,
- fontSize = 14.sp,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier,
- )
- }
+ if (startedBeforeGuide) {
+ Text(
+ text = stringResource(R.string.fa_caret_left),
+ fontFamily = FontAwesome,
+ color = textColor,
+ fontSize = 16.sp,
+ modifier =
+ Modifier
+ .align(Alignment.CenterVertically)
+ .padding(start = 2.dp),
+ )
+ }
+ Column(
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(start = 2.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
+ ) {
+ Text(
+ text = title,
+ color = textColor,
+ fontSize = 16.sp,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier,
+ )
+ listOfNotNull(
+ program.seasonEpisode?.let { "S${it.season} E${it.episode}" },
+ program.subtitle,
+ ).joinToString(" - ")
+ .ifBlank { null }
+ ?.let {
+ Text(
+ text = it,
+ color = textColor,
+ fontSize = 14.sp,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier,
+ )
+ }
+ }
}
RecordingMarker(
isRecording = program.isRecording,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt
index 0d86729d..9bce1dfe 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt
@@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.detail.livetv
import android.content.Context
import androidx.compose.ui.graphics.Color
+import androidx.datastore.core.DataStore
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -9,6 +10,7 @@ import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.data.RowColumn
@@ -23,7 +25,13 @@ 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.FlowPreview
import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -34,7 +42,9 @@ import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.GetProgramsDto
import org.jellyfin.sdk.model.api.ImageType
+import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy
+import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.TimerInfoDto
import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest
import org.jellyfin.sdk.model.extensions.ticks
@@ -45,11 +55,13 @@ import java.util.UUID
import javax.inject.Inject
import kotlin.math.min
import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
const val MAX_HOURS = 48L
+@OptIn(FlowPreview::class)
@HiltViewModel
class LiveTvViewModel
@Inject
@@ -57,15 +69,15 @@ class LiveTvViewModel
@param:ApplicationContext private val context: Context,
val api: ApiClient,
val navigationManager: NavigationManager,
+ val preferences: DataStore,
private val serverRepository: ServerRepository,
) : ViewModel() {
val loading = MutableLiveData(LoadingState.Pending)
- lateinit var guideStart: LocalDateTime
- private set
private lateinit var channelsIdToIndex: Map
private val mutex = Mutex()
+ val guideTimes = MutableLiveData>(buildGuideTimes())
val channels = MutableLiveData>()
val channelProgramCount = mutableMapOf()
val programs = MutableLiveData()
@@ -75,11 +87,25 @@ class LiveTvViewModel
private val range = 100
- fun init(firstLoad: Boolean) {
- guideStart = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS)
- if (!firstLoad) {
- loading.value = LoadingState.Loading
+ init {
+ viewModelScope.launchIO {
+ preferences.data
+ .map {
+ it.interfacePreferences.liveTvPreferences.let {
+ Pair(it.sortByRecentlyWatched, it.favoriteChannelsAtBeginning)
+ }
+ }.distinctUntilChanged()
+ .debounce { 500.milliseconds }
+ .collectLatest {
+ Timber.v("Init due to pref change")
+ loading.setValueOnMain(LoadingState.Pending)
+ init()
+ }
}
+ }
+
+ fun init() {
+ val guideStart = guideTimes.value!!.first()
viewModelScope.launch(
Dispatchers.IO +
LoadingExceptionHandler(
@@ -87,11 +113,26 @@ class LiveTvViewModel
"Could not fetch channels",
),
) {
+ val prefs =
+ (preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance())
+ .interfacePreferences.liveTvPreferences
val channelData by api.liveTvApi.getLiveTvChannels(
GetLiveTvChannelsRequest(
startIndex = 0,
userId = serverRepository.currentUser.value?.id,
- enableFavoriteSorting = true,
+ enableFavoriteSorting = prefs.favoriteChannelsAtBeginning,
+ sortBy =
+ if (prefs.sortByRecentlyWatched) {
+ listOf(ItemSortBy.DATE_PLAYED)
+ } else {
+ null
+ },
+ sortOrder =
+ if (prefs.sortByRecentlyWatched) {
+ SortOrder.DESCENDING
+ } else {
+ null
+ },
addCurrentProgram = false,
),
)
@@ -111,7 +152,7 @@ class LiveTvViewModel
// Initially, quickly load the first 10 channels (only some are visible immediately), then below will load more
// This makes the guide appear faster, and load more usable data in the background
val initial = 10
- fetchPrograms(channels, 0.. initial) {
- fetchPrograms(channels, 0..,
range: IntRange,
) {
loading.setValueOnMain(LoadingState.Loading)
- fetchPrograms(channels, range)
+ val guideStart = guideTimes.value!!.first()
+ fetchPrograms(guideStart, channels, range)
loading.setValueOnMain(LoadingState.Success)
}
private suspend fun fetchPrograms(
+ guideStart: LocalDateTime,
channels: List,
range: IntRange,
) = mutex.withLock {
@@ -148,6 +203,7 @@ class LiveTvViewModel
channelIds = channelsToFetch.map { it.id },
sortBy = listOf(ItemSortBy.START_DATE),
userId = serverRepository.currentUser.value?.id,
+ fields = listOf(ItemFields.OVERVIEW),
)
val fetchedPrograms =
api.liveTvApi
@@ -173,6 +229,12 @@ class LiveTvViewModel
} else {
null
}
+ // Clean up name/subtitles by collapsing whitespace (including newlines) into single spaces
+ val name = (dto.seriesName ?: dto.name)?.replace(Regex("\\s+"), " ")
+ val subtitle =
+ dto.episodeTitle
+ .takeIf { dto.isSeries ?: false }
+ ?.replace(Regex("\\s+"), " ")
val p =
TvProgram(
id = dto.id,
@@ -186,8 +248,10 @@ class LiveTvViewModel
).coerceAtLeast(0f),
endHours = hoursBetween(guideStart, dto.endDate!!),
duration = dto.runTimeTicks!!.ticks,
- name = dto.seriesName ?: dto.name,
- subtitle = dto.episodeTitle.takeIf { dto.isSeries ?: false },
+ name = name,
+ subtitle = subtitle,
+ overview = dto.overview,
+ officialRating = dto.officialRating,
seasonEpisode =
if (dto.indexNumber != null && dto.parentIndexNumber != null) {
SeasonEpisode(
@@ -474,11 +538,13 @@ data class TvProgram(
val duration: Duration,
val name: String?,
val subtitle: String?,
+ val overview: String? = null,
val seasonEpisode: SeasonEpisode?,
val isRecording: Boolean,
val isSeriesRecording: Boolean,
val isRepeat: Boolean,
val category: ProgramCategory?,
+ val officialRating: String? = null,
) {
val isFake = category == ProgramCategory.FAKE
@@ -500,6 +566,7 @@ data class TvProgram(
duration = ((endHours - startHours) * 60).toInt().minutes,
name = NO_DATA,
subtitle = null,
+ overview = null,
seasonEpisode = null,
isRecording = false,
isSeriesRecording = false,
@@ -524,3 +591,8 @@ data class FetchedPrograms(
val programs: List,
val programsByChannel: Map>,
)
+
+fun LocalDateTime.roundDownToHalfHour(): LocalDateTime {
+ val min = minute % 30L
+ return minusMinutes(min).truncatedTo(ChronoUnit.MINUTES)
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewOptionsDialog.kt
new file mode 100644
index 00000000..a0971f8e
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewOptionsDialog.kt
@@ -0,0 +1,94 @@
+package com.github.damontecres.wholphin.ui.detail.livetv
+
+import android.view.Gravity
+import androidx.compose.foundation.background
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+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.platform.LocalView
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import androidx.compose.ui.window.DialogWindowProvider
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import androidx.tv.material3.surfaceColorAtElevation
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.preferences.AppPreference
+import com.github.damontecres.wholphin.preferences.AppPreferences
+import com.github.damontecres.wholphin.preferences.liveTvPreferences
+import com.github.damontecres.wholphin.ui.preferences.ComposablePreference
+import com.github.damontecres.wholphin.ui.tryRequestFocus
+
+@Composable
+fun LiveTvViewOptionsDialog(
+ preferences: AppPreferences,
+ onDismissRequest: () -> Unit,
+ onViewOptionsChange: (AppPreferences) -> Unit,
+) {
+ val focusRequester = remember { FocusRequester() }
+ LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
+ val columnState = rememberLazyListState()
+ Dialog(
+ onDismissRequest = onDismissRequest,
+ properties =
+ DialogProperties(
+ usePlatformDefaultWidth = false,
+ ),
+ ) {
+ val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider
+ dialogWindowProvider?.window?.let { window ->
+ window.setGravity(Gravity.END)
+ window.setDimAmount(0f)
+ }
+ LazyColumn(
+ state = columnState,
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(0.dp),
+ modifier =
+ Modifier
+ .width(256.dp)
+ .heightIn(max = 380.dp)
+ .focusRequester(focusRequester)
+ .background(
+ MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp),
+ shape = RoundedCornerShape(8.dp),
+ ),
+ ) {
+ stickyHeader {
+ Text(
+ text = stringResource(R.string.view_options),
+ style = MaterialTheme.typography.titleMedium,
+ )
+ }
+ items(liveTvPreferences) { pref ->
+ pref as AppPreference
+ val interactionSource = remember { MutableInteractionSource() }
+ val value = pref.getter.invoke(preferences)
+ ComposablePreference(
+ preference = pref,
+ value = value,
+ onNavigate = {},
+ onValueChange = { newValue ->
+ onViewOptionsChange.invoke(pref.setter(preferences, newValue))
+ },
+ interactionSource = interactionSource,
+ modifier = Modifier,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt
index d0a811fc..68c62063 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt
@@ -2,10 +2,13 @@ package com.github.damontecres.wholphin.ui.detail.livetv
import android.text.format.DateUtils
import android.widget.Toast
+import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -13,6 +16,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
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
@@ -24,6 +28,7 @@ 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.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.key.Key
@@ -39,10 +44,15 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.SurfaceDefaults
import androidx.tv.material3.Text
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.preferences.AppPreferences
+import com.github.damontecres.wholphin.preferences.LiveTvPreferences
import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.ErrorMessage
+import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.data.RowColumn
+import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.ui.tryRequestFocus
@@ -66,16 +76,21 @@ fun TvGuideGrid(
modifier: Modifier = Modifier,
viewModel: LiveTvViewModel = hiltViewModel(),
) {
- var firstLoad by rememberSaveable { mutableStateOf(true) }
- LaunchedEffect(Unit) {
- viewModel.init(firstLoad)
- firstLoad = false
- }
+ val scope = rememberCoroutineScope()
+// var firstLoad by rememberSaveable { mutableStateOf(true) }
+// LaunchedEffect(Unit) {
+// viewModel.init(firstLoad)
+// firstLoad = false
+// }
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
val channels by viewModel.channels.observeAsState(listOf())
val programs by viewModel.programs.observeAsState(FetchedPrograms(0..0, listOf(), mapOf()))
// val programsByChannel by viewModel.programsByChannel.observeAsState(mapOf())
// val fetchedRange by viewModel.fetchedRange.observeAsState(0..0)
+ val preferences by viewModel.preferences.data
+ .collectAsState(AppPreferences.getDefaultInstance())
+ val tvPrefs = preferences.interfacePreferences.liveTvPreferences
+ var showViewOptions by remember { mutableStateOf(false) }
when (val state = loading) {
is LoadingState.Error -> {
ErrorMessage(state, modifier)
@@ -90,25 +105,59 @@ fun TvGuideGrid(
LoadingState.Success,
-> {
val context = LocalContext.current
+ val guideTimes by viewModel.guideTimes.observeAsState(listOf())
val fetchedItem by viewModel.fetchedItem.observeAsState(null)
val loadingItem by viewModel.fetchingItem.observeAsState(LoadingState.Pending)
var showItemDialog by remember { mutableStateOf(null) }
val focusRequester = remember { FocusRequester() }
+ val buttonFocusRequester = remember { FocusRequester() }
if (requestFocusAfterLoading) {
LaunchedEffect(Unit) {
focusRequester.tryRequestFocus()
}
}
- Column(modifier = modifier) {
+ var focusedPosition by rememberPosition(0, 0)
+ val focusedProgram =
+ remember(focusedPosition) {
+ focusedPosition.let {
+ val channelId = channels.getOrNull(it.row)?.id
+ programs.programsByChannel[channelId]?.getOrNull(it.column)
+ }
+ }
+ Column(
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ modifier = modifier,
+ ) {
if (channels.isEmpty()) {
ErrorMessage("Live TV is enabled, but no channels were found.", null)
} else {
+ AnimatedVisibility(tvPrefs.showHeader) {
+ TvGuideHeader(
+ program = focusedProgram,
+ modifier =
+ Modifier
+ .padding(top = 24.dp, bottom = 16.dp, start = 32.dp)
+ .fillMaxHeight(.30f),
+ )
+ }
+ AnimatedVisibility(focusedPosition.row < 1) {
+ ExpandableFaButton(
+ title = R.string.view_options,
+ iconStringRes = R.string.fa_sliders,
+ onClick = { showViewOptions = true },
+ modifier =
+ Modifier
+ .padding(start = tvGuideDimensions.channelWidth)
+ .focusRequester(buttonFocusRequester),
+ )
+ }
TvGuideGridContent(
+ preferences = tvPrefs,
loading = state is LoadingState.Loading,
channels = channels,
programs = programs,
channelProgramCount = viewModel.channelProgramCount,
- start = viewModel.guideStart,
+ guideTimes = guideTimes,
onClickChannel = { index, channel ->
viewModel.navigationManager.navigateTo(
Destination.Playback(
@@ -118,6 +167,7 @@ fun TvGuideGrid(
)
},
onFocus = {
+ focusedPosition = it
onRowPosition.invoke(it.row)
viewModel.onFocusChannel(it)
},
@@ -144,6 +194,7 @@ fun TvGuideGrid(
showItemDialog = index
}
},
+ buttonFocusRequester = buttonFocusRequester,
modifier =
Modifier
.fillMaxSize()
@@ -192,24 +243,49 @@ fun TvGuideGrid(
}
}
}
+ if (showViewOptions) {
+ LiveTvViewOptionsDialog(
+ preferences = preferences,
+ onDismissRequest = { showViewOptions = false },
+ onViewOptionsChange = { newPrefs ->
+ scope.launchIO {
+ viewModel.preferences.updateData {
+ newPrefs
+ }
+ }
+ },
+ )
+ }
}
+val tvGuideDimensions =
+ ProgramGuideDimensions(
+ timelineHourWidth = 240.dp,
+ timelineHeight = 32.dp,
+ channelWidth = 120.dp,
+ channelHeight = 64.dp,
+ currentTimeWidth = 2.dp,
+ )
+
@Composable
fun TvGuideGridContent(
+ preferences: LiveTvPreferences,
loading: Boolean,
channels: List,
programs: FetchedPrograms,
channelProgramCount: Map,
- start: LocalDateTime,
+ guideTimes: List,
onClickChannel: (Int, TvChannel) -> Unit,
onClickProgram: (Int, TvProgram) -> Unit,
onFocus: (RowColumn) -> Unit,
+ buttonFocusRequester: FocusRequester,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val focusManager = LocalFocusManager.current
val state = rememberSaveableProgramGuideState()
val scope = rememberCoroutineScope()
+ val guideStart = guideTimes.first()
var focusedItem by rememberPosition(RowColumn(0, 0))
val focusedChannelIndex = focusedItem.row
@@ -229,25 +305,19 @@ fun TvGuideGridContent(
state.animateToProgram(focusedProgramIndex, Alignment.Center)
}
}
- val dimensions =
- ProgramGuideDimensions(
- timelineHourWidth = 240.dp,
- timelineHeight = 32.dp,
- channelWidth = 120.dp,
- channelHeight = 64.dp,
- currentTimeWidth = 2.dp,
- )
var gridHasFocus by rememberSaveable { mutableStateOf(false) }
var channelColumnFocused by rememberSaveable { mutableStateOf(false) }
Box(modifier = modifier) {
ProgramGuide(
state = state,
- dimensions = dimensions,
+ dimensions = tvGuideDimensions,
modifier =
Modifier
.fillMaxSize()
.onFocusChanged {
gridHasFocus = it.hasFocus
+ }.focusProperties {
+ up = buttonFocusRequester
}.focusable()
.onPreviewKeyEvent {
if (it.type == KeyEventType.KeyUp) {
@@ -291,7 +361,7 @@ fun TvGuideGridContent(
Key.DirectionUp -> {
if (item.row <= 0) {
- focusManager.moveFocus(FocusDirection.Up)
+// focusManager.moveFocus(FocusDirection.Up)
null
} else {
val newChannelIndex = item.row - 1
@@ -438,7 +508,7 @@ fun TvGuideGridContent(
currentTime(
layoutInfo = {
ProgramGuideItem.CurrentTime(
- hoursBetween(start, LocalDateTime.now()),
+ hoursBetween(guideStart, LocalDateTime.now()),
)
},
) {
@@ -455,11 +525,18 @@ fun TvGuideGridContent(
}
}
timeline(
- count = MAX_HOURS.toInt(),
+ count = guideTimes.size,
layoutInfo = { index ->
+ val start = guideTimes[index]
+ val end =
+ if (index < guideTimes.lastIndex) {
+ guideTimes[index + 1]
+ } else {
+ start.plusHours(1)
+ }
ProgramGuideItem.Timeline(
- startHour = index.toFloat(),
- endHour = index + 1f,
+ startHour = hoursBetween(guideStart, start),
+ endHour = hoursBetween(guideStart, end),
)
},
) { index ->
@@ -477,16 +554,12 @@ fun TvGuideGridContent(
shape = RoundedCornerShape(4.dp),
),
) {
- val differentDay =
- start.toLocalDate() !=
- start
- .plusHours(index.toLong())
- .toLocalDate()
+ val guideTime = guideTimes[index]
+ val differentDay = guideTime.toLocalDate() != guideStart.toLocalDate()
val time =
DateUtils.formatDateTime(
context,
- start
- .plusHours(index.toLong())
+ guideTime
.toInstant(OffsetDateTime.now().offset)
.epochSecond * 1000,
DateUtils.FORMAT_SHOW_TIME or if (differentDay) DateUtils.FORMAT_SHOW_WEEKDAY else 0,
@@ -516,7 +589,7 @@ fun TvGuideGridContent(
val program = programs.programs[programIndex]
val focused =
gridHasFocus && !channelColumnFocused && programIndex == focusedProgramIndex
- Program(program, focused, Modifier)
+ Program(guideStart, program, focused, preferences.colorCodePrograms, Modifier)
}
channels(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideHeader.kt
new file mode 100644
index 00000000..73b9b3c2
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideHeader.kt
@@ -0,0 +1,122 @@
+package com.github.damontecres.wholphin.ui.detail.livetv
+
+import android.text.format.DateUtils
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.ui.components.DotSeparatedRow
+import com.github.damontecres.wholphin.ui.components.OverviewText
+import com.github.damontecres.wholphin.ui.components.StreamLabel
+import com.github.damontecres.wholphin.ui.roundMinutes
+import java.time.LocalDateTime
+import java.time.ZoneId
+import kotlin.time.toKotlinDuration
+
+@Composable
+fun TvGuideHeader(
+ program: TvProgram?,
+ modifier: Modifier = Modifier,
+) {
+ val context = LocalContext.current
+ val now = LocalDateTime.now()
+ val details =
+ remember(program) {
+ buildList {
+ program?.let {
+ val differentDay = it.start.toLocalDate() != now.toLocalDate()
+ val time =
+ DateUtils.formatDateRange(
+ context,
+ it.start
+ .atZone(ZoneId.systemDefault())
+ .toInstant()
+ .epochSecond * 1000,
+ it.end
+ .atZone(ZoneId.systemDefault())
+ .toInstant()
+ .epochSecond * 1000,
+ DateUtils.FORMAT_SHOW_TIME or if (differentDay) DateUtils.FORMAT_SHOW_WEEKDAY else 0,
+ )
+ add(time)
+ }
+ if (program?.isFake == false) {
+ program
+ .duration
+ .roundMinutes
+ .toString()
+ .let(::add)
+ if (now.isAfter(program.start) && now.isBefore(program.end)) {
+ java.time.Duration
+ .between(now, program.end)
+ .toKotlinDuration()
+ .roundMinutes
+ .let { add("$it left") }
+ }
+ program.seasonEpisode?.let { "S${it.season} E${it.episode}" }?.let(::add)
+ program.officialRating?.let(::add)
+ }
+ }
+ }
+ Column(
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ horizontalAlignment = Alignment.Start,
+ modifier = modifier,
+ ) {
+ Text(
+ text = program?.name ?: program?.id.toString(),
+ style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
+ color = MaterialTheme.colorScheme.onBackground,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.fillMaxWidth(.75f),
+ )
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.fillMaxWidth(.6f),
+ ) {
+ program?.subtitle?.let {
+ Text(
+ text = program.subtitle,
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onBackground,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ DotSeparatedRow(
+ texts = details,
+ communityRating = null,
+ criticRating = null,
+ textStyle = MaterialTheme.typography.titleSmall,
+ modifier = Modifier,
+ )
+ if (program?.isRepeat == true) {
+ StreamLabel(stringResource(R.string.live_tv_repeat))
+ }
+ }
+ OverviewText(
+ overview = program?.overview ?: "",
+ maxLines = 3,
+ onClick = {},
+ enabled = false,
+ )
+ }
+ }
+}
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 aafc4c03..24d2f98e 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
@@ -45,7 +45,6 @@ import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.RemoteTrailer
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.data.model.aspectRatioFloat
-import com.github.damontecres.wholphin.data.model.chooseSource
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.ui.AspectRatios
@@ -55,7 +54,6 @@ import com.github.damontecres.wholphin.ui.cards.ExtrasRow
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.cards.PersonRow
import com.github.damontecres.wholphin.ui.cards.SeasonCard
-import com.github.damontecres.wholphin.ui.components.DetailsBackdropImage
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage
@@ -83,6 +81,7 @@ import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUID
+import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID
import kotlin.time.Duration
@@ -204,7 +203,7 @@ fun MovieDetails(
watched = movie.data.userData?.played ?: false,
favorite = movie.data.userData?.isFavorite ?: false,
seriesId = null,
- sourceId = chosenStreams?.sourceId,
+ sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
actions = moreActions,
onChooseVersion = {
chooseVersion =
@@ -221,25 +220,37 @@ fun MovieDetails(
moreDialog = null
},
onChooseTracks = { type ->
- chooseSource(
- movie.data,
- chosenStreams?.itemPlayback,
- )?.let { source ->
- chooseVersion =
- chooseStream(
- context = context,
- streams = source.mediaStreams.orEmpty(),
- type = type,
- onClick = { trackIndex ->
- viewModel.saveTrackSelection(
- movie,
- chosenStreams?.itemPlayback,
- trackIndex,
- type,
- )
- },
- )
- }
+ viewModel.streamChoiceService
+ .chooseSource(
+ movie.data,
+ chosenStreams?.itemPlayback,
+ )?.let { source ->
+ chooseVersion =
+ chooseStream(
+ context = context,
+ streams = source.mediaStreams.orEmpty(),
+ type = type,
+ onClick = { trackIndex ->
+ viewModel.saveTrackSelection(
+ movie,
+ chosenStreams?.itemPlayback,
+ trackIndex,
+ type,
+ )
+ },
+ )
+ }
+ },
+ onShowOverview = {
+ overviewDialog =
+ ItemDetailsDialogInfo(
+ title =
+ movie.name
+ ?: context.getString(R.string.unknown),
+ overview = movie.data.overview,
+ genres = movie.data.genres.orEmpty(),
+ files = movie.data.mediaSources.orEmpty(),
+ )
},
),
)
@@ -384,7 +395,6 @@ fun MovieDetailsContent(
focusRequesters.getOrNull(position)?.tryRequestFocus()
}
Box(modifier = modifier) {
- DetailsBackdropImage(movie)
LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp),
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt
index 71115a9a..c976e79b 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt
@@ -13,6 +13,7 @@ import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@@ -53,6 +54,7 @@ fun MovieDetailsHeader(
text = movie.name ?: "",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.displaySmall,
+ fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(.75f),
@@ -70,9 +72,7 @@ fun MovieDetailsHeader(
}
VideoStreamDetails(
- preferences = preferences,
- dto = dto,
- itemPlayback = chosenStreams?.itemPlayback,
+ chosenStreams = chosenStreams,
modifier = Modifier.padding(bottom = padding),
)
dto.taglines?.firstOrNull()?.let { tagline ->
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt
index 17a68afc..be62b4aa 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt
@@ -14,12 +14,15 @@ import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.preferences.ThemeSongVolume
+import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.ExtrasService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PeopleFavorites
+import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.TrailerService
+import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
@@ -54,11 +57,14 @@ class MovieViewModel
private val navigationManager: NavigationManager,
val serverRepository: ServerRepository,
val itemPlaybackRepository: ItemPlaybackRepository,
+ val streamChoiceService: StreamChoiceService,
private val themeSongPlayer: ThemeSongPlayer,
private val favoriteWatchManager: FavoriteWatchManager,
private val peopleFavorites: PeopleFavorites,
private val trailerService: TrailerService,
private val extrasService: ExtrasService,
+ private val userPreferencesService: UserPreferencesService,
+ private val backdropService: BackdropService,
@Assisted val itemId: UUID,
) : ViewModel() {
@AssistedFactory
@@ -104,11 +110,17 @@ class MovieViewModel
),
) {
val item = fetchAndSetItem().await()
- val result = itemPlaybackRepository.getSelectedTracks(item.id, item)
+ val result =
+ itemPlaybackRepository.getSelectedTracks(
+ item.id,
+ item,
+ userPreferencesService.getCurrent(),
+ )
withContext(Dispatchers.Main) {
this@MovieViewModel.item.value = item
chosenStreams.value = result
loading.value = LoadingState.Success
+ backdropService.submit(item)
}
viewModelScope.launchIO {
val trailers = trailerService.getTrailers(item)
@@ -172,10 +184,12 @@ class MovieViewModel
sourceId: UUID,
) {
viewModelScope.launchIO {
+ val prefs = userPreferencesService.getCurrent()
+ val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
val chosen =
result?.let {
- itemPlaybackRepository.getChosenItemFromPlayback(item, result)
+ itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
}
withContext(Dispatchers.Main) {
chosenStreams.value = chosen
@@ -190,6 +204,8 @@ class MovieViewModel
type: MediaStreamType,
) {
viewModelScope.launchIO {
+ val prefs = userPreferencesService.getCurrent()
+ val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result =
itemPlaybackRepository.saveTrackSelection(
item = item,
@@ -199,7 +215,7 @@ class MovieViewModel
)
val chosen =
result?.let {
- itemPlaybackRepository.getChosenItemFromPlayback(item, result)
+ itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
}
withContext(Dispatchers.Main) {
chosenStreams.value = chosen
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 30c95c95..5b25783b 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
@@ -37,9 +37,7 @@ fun FocusedEpisodeHeader(
if (dto != null) {
VideoStreamDetails(
- preferences = preferences,
- dto = dto,
- itemPlayback = chosenStreams?.itemPlayback,
+ chosenStreams = chosenStreams,
modifier = Modifier,
)
}
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 cf75ecc6..0ab81b7e 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
@@ -51,7 +51,6 @@ import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.cards.PersonRow
import com.github.damontecres.wholphin.ui.cards.SeasonCard
import com.github.damontecres.wholphin.ui.components.ConfirmDialog
-import com.github.damontecres.wholphin.ui.components.DetailsBackdropImage
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
@@ -320,8 +319,6 @@ fun SeriesDetailsContent(
Box(
modifier = modifier,
) {
- DetailsBackdropImage(series)
-
Column(
modifier =
Modifier
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 61ef8d6b..73f489c1 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
@@ -21,7 +21,6 @@ import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.lifecycle.map
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
-import com.github.damontecres.wholphin.data.model.chooseSource
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.components.DialogParams
@@ -47,12 +46,12 @@ import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.BaseItemKind
-import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.PersonKind
import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import org.jellyfin.sdk.model.serializer.toUUID
+import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.UUID
import kotlin.time.Duration
@@ -219,7 +218,7 @@ fun SeriesOverview(
watched = ep.data.userData?.played ?: false,
favorite = ep.data.userData?.isFavorite ?: false,
seriesId = series.id,
- sourceId = chosenStreams?.sourceId,
+ sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
actions =
MoreDialogActions(
navigateTo = viewModel::navigateTo,
@@ -257,25 +256,35 @@ fun SeriesOverview(
moreDialog = null
},
onChooseTracks = { type ->
- chooseSource(
- ep.data,
- chosenStreams?.itemPlayback,
- )?.let { source ->
- chooseVersion =
- chooseStream(
- context = context,
- streams = source.mediaStreams.orEmpty(),
- type = type,
- onClick = { trackIndex ->
- viewModel.saveTrackSelection(
- ep,
- chosenStreams?.itemPlayback,
- trackIndex,
- type,
- )
- },
- )
- }
+ viewModel.streamChoiceService
+ .chooseSource(
+ ep.data,
+ chosenStreams?.itemPlayback,
+ )?.let { source ->
+ chooseVersion =
+ chooseStream(
+ context = context,
+ streams = source.mediaStreams.orEmpty(),
+ type = type,
+ onClick = { trackIndex ->
+ viewModel.saveTrackSelection(
+ ep,
+ chosenStreams?.itemPlayback,
+ trackIndex,
+ type,
+ )
+ },
+ )
+ }
+ },
+ onShowOverview = {
+ overviewDialog =
+ ItemDetailsDialogInfo(
+ title = ep.name ?: context.getString(R.string.unknown),
+ overview = ep.data.overview,
+ genres = ep.data.genres.orEmpty(),
+ files = ep.data.mediaSources.orEmpty(),
+ )
},
),
)
@@ -288,13 +297,6 @@ fun SeriesOverview(
chosenStreams = chosenStreams,
peopleInEpisode = peopleInEpisode,
position = position,
- backdropImageUrl =
- remember {
- viewModel.imageUrl(
- series.id,
- ImageType.BACKDROP,
- )
- },
firstItemFocusRequester = firstItemFocusRequester,
episodeRowFocusRequester = episodeRowFocusRequester,
castCrewRowFocusRequester = castCrewRowFocusRequester,
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 66b6b414..fa225688 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
@@ -52,7 +52,6 @@ import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.cards.BannerCard
import com.github.damontecres.wholphin.ui.cards.PersonRow
-import com.github.damontecres.wholphin.ui.components.DetailsBackdropImage
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.SeriesName
@@ -75,7 +74,6 @@ fun SeriesOverviewContent(
chosenStreams: ChosenStreams?,
peopleInEpisode: List,
position: SeriesOverviewPosition,
- backdropImageUrl: String?,
firstItemFocusRequester: FocusRequester,
episodeRowFocusRequester: FocusRequester,
castCrewRowFocusRequester: FocusRequester,
@@ -112,7 +110,6 @@ fun SeriesOverviewContent(
modifier
.fillMaxWidth(),
) {
- DetailsBackdropImage(backdropImageUrl)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
@@ -218,9 +215,7 @@ fun SeriesOverviewContent(
item = episode,
aspectRatio =
episode
- ?.data
- ?.primaryImageAspectRatio
- ?.toFloat()
+ ?.aspectRatio
?.coerceAtLeast(AspectRatios.FOUR_THREE)
?: (AspectRatios.WIDE),
cornerText = cornerText,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt
index 4185cb0b..5dbe62f8 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt
@@ -14,12 +14,15 @@ import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.preferences.ThemeSongVolume
import com.github.damontecres.wholphin.preferences.UserPreferences
+import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.ExtrasService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PeopleFavorites
+import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.TrailerService
+import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.detail.ItemViewModel
import com.github.damontecres.wholphin.ui.equalsNotNull
@@ -70,6 +73,9 @@ class SeriesViewModel
private val peopleFavorites: PeopleFavorites,
private val trailerService: TrailerService,
private val extrasService: ExtrasService,
+ val streamChoiceService: StreamChoiceService,
+ private val userPreferencesService: UserPreferencesService,
+ private val backdropService: BackdropService,
) : ItemViewModel(api) {
private lateinit var seriesId: UUID
private lateinit var prefs: UserPreferences
@@ -99,6 +105,7 @@ class SeriesViewModel
) + Dispatchers.IO,
) {
val item = fetchItem(seriesId)
+ backdropService.submit(item)
val seasons = getSeasons(item)
// If a particular season was requested, fetch those episodes, otherwise get the first season
@@ -328,6 +335,8 @@ class SeriesViewModel
episodes.value = eps
}
}
+ // Kind of hack to ensure the backdrop is reloaded if needed
+ item.value?.let { backdropService.submit(it) }
}
/**
@@ -372,7 +381,12 @@ class SeriesViewModel
chosenStreamsJob?.cancel()
chosenStreamsJob =
viewModelScope.launchIO {
- val result = itemPlaybackRepository.getSelectedTracks(itemId, item)
+ val result =
+ itemPlaybackRepository.getSelectedTracks(
+ itemId,
+ item,
+ userPreferencesService.getCurrent(),
+ )
withContext(Dispatchers.Main) {
chosenStreams.value = result
}
@@ -384,10 +398,12 @@ class SeriesViewModel
sourceId: UUID,
) {
viewModelScope.launchIO {
+ val prefs = userPreferencesService.getCurrent()
+ val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
val chosen =
result?.let {
- itemPlaybackRepository.getChosenItemFromPlayback(item, result)
+ itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
}
withContext(Dispatchers.Main) {
chosenStreams.value = chosen
@@ -402,6 +418,8 @@ class SeriesViewModel
type: MediaStreamType,
) {
viewModelScope.launchIO {
+ val prefs = userPreferencesService.getCurrent()
+ val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result =
itemPlaybackRepository.saveTrackSelection(
item = item,
@@ -411,7 +429,7 @@ class SeriesViewModel
)
val chosen =
result?.let {
- itemPlaybackRepository.getChosenItemFromPlayback(item, result)
+ itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
}
withContext(Dispatchers.Main) {
chosenStreams.value = chosen
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 1dc6b7b0..67b4200b 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
@@ -34,6 +34,7 @@ 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.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@@ -50,7 +51,6 @@ import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.cards.BannerCard
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.components.CircularProgress
-import com.github.damontecres.wholphin.ui.components.DelayedDetailsBackdropImage
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.EpisodeQuickDetails
@@ -67,12 +67,16 @@ import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.ifElse
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.tryRequestFocus
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.delay
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaType
+import timber.log.Timber
import java.util.UUID
@Composable
@@ -157,8 +161,12 @@ fun HomePage(
items = dialogItems,
)
},
+ onClickPlay = { _, item ->
+ viewModel.navigationManager.navigateTo(Destination.Playback(item))
+ },
loadingState = refreshing,
showClock = preferences.appPreferences.interfacePreferences.showClock,
+ onUpdateBackdrop = viewModel::updateBackdrop,
modifier = modifier,
)
dialog?.let { params ->
@@ -193,7 +201,9 @@ fun HomePageContent(
homeRows: List,
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,
@@ -240,12 +250,10 @@ fun HomePageContent(
LaunchedEffect(position) {
listState.animateScrollToItem(position.row)
}
+ LaunchedEffect(focusedItem) {
+ focusedItem?.let(onUpdateBackdrop)
+ }
Box(modifier = modifier) {
- DelayedDetailsBackdropImage(
- item = focusedItem,
- modifier = Modifier,
- )
-
Column(modifier = Modifier.fillMaxSize()) {
HomePageHeader(
item = focusedItem,
@@ -264,7 +272,18 @@ fun HomePageContent(
top = 0.dp,
bottom = Cards.height2x3,
),
- modifier = Modifier.focusRestorer(),
+ modifier =
+ Modifier
+ .focusRestorer()
+ .onKeyEvent {
+ val item = focusedItem
+ if (isPlayKeyUp(it) && item?.type?.playable == true) {
+ Timber.v("Clicked play on ${item.id}")
+ onClickPlay.invoke(position, item)
+ return@onKeyEvent true
+ }
+ return@onKeyEvent false
+ },
) {
itemsIndexed(homeRows) { rowIndex, row ->
when (val r = row) {
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 834b0102..38c1ff4f 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
@@ -9,10 +9,12 @@ 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.services.BackdropService
import com.github.damontecres.wholphin.services.DatePlayedService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.SlimItemFields
+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.util.ExceptionHandler
@@ -58,6 +60,7 @@ class HomeViewModel
val navDrawerItemRepository: NavDrawerItemRepository,
private val favoriteWatchManager: FavoriteWatchManager,
private val datePlayedService: DatePlayedService,
+ private val backdropService: BackdropService,
) : ViewModel() {
val loadingState = MutableLiveData(LoadingState.Pending)
val refreshState = MutableLiveData(LoadingState.Pending)
@@ -316,6 +319,12 @@ class HomeViewModel
init(preferences)
}
}
+
+ fun updateBackdrop(item: BaseItem) {
+ viewModelScope.launchIO {
+ backdropService.submit(item)
+ }
+ }
}
val supportedLatestCollectionTypes =
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt
index 386c7f4f..602e2a46 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt
@@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
-import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -196,7 +195,8 @@ fun SearchPage(
val episodes by viewModel.episodes.observeAsState(SearchResult.NoQuery)
val seerrResults by viewModel.seerrResults.observeAsState(SearchResult.NoQuery)
- val query = rememberTextFieldState()
+// val query = rememberTextFieldState()
+ var query by rememberSaveable { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
var position by rememberPosition()
@@ -204,7 +204,7 @@ fun SearchPage(
LaunchedEffect(query) {
delay(750L)
- viewModel.search(query.text.toString())
+ viewModel.search(query)
}
LaunchedEffect(Unit) {
focusRequester.tryRequestFocus()
@@ -238,9 +238,10 @@ fun SearchPage(
focusManager.moveFocus(FocusDirection.Next)
}
SearchEditTextBox(
- state = query,
+ value = query,
+ onValueChange = { query = it },
onSearchClick = {
- viewModel.search(query.text.toString())
+ viewModel.search(query)
searchClicked = true
},
modifier =
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt
index 8e546fdb..2d3200ba 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt
@@ -1,17 +1,59 @@
package com.github.damontecres.wholphin.ui.nav
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
+import androidx.tv.material3.MaterialTheme
+import coil3.compose.AsyncImage
+import coil3.request.ImageRequest
+import coil3.request.transitionFactory
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
+import com.github.damontecres.wholphin.preferences.BackdropStyle
import com.github.damontecres.wholphin.preferences.UserPreferences
+import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager
+import com.github.damontecres.wholphin.ui.CrossFadeFactory
import com.github.damontecres.wholphin.ui.components.ErrorMessage
+import com.github.damontecres.wholphin.ui.launchIO
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlin.time.Duration.Companion.milliseconds
+
+@HiltViewModel
+class ApplicationContentViewModel
+ @Inject
+ constructor(
+ val backdropService: BackdropService,
+ ) : ViewModel() {
+ fun clearBackdrop() {
+ viewModelScope.launchIO { backdropService.clearBackdrop() }
+ }
+ }
/**
* This is generally the root composable of the of the app
@@ -25,37 +67,158 @@ fun ApplicationContent(
navigationManager: NavigationManager,
preferences: UserPreferences,
modifier: Modifier = Modifier,
+ viewModel: ApplicationContentViewModel = hiltViewModel(),
) {
- NavDisplay(
- backStack = navigationManager.backStack,
- onBack = { navigationManager.goBack() },
- entryDecorators =
- listOf(
- rememberSaveableStateHolderNavEntryDecorator(),
- rememberViewModelStoreNavEntryDecorator(),
- ),
- entryProvider = { key ->
- key as Destination
- val contentKey = "${key}_${server?.id}_${user?.id}"
- NavEntry(key, contentKey = contentKey) {
- if (key.fullScreen) {
- DestinationContent(
- destination = key,
- preferences = preferences,
- modifier = modifier.fillMaxSize(),
- )
- } else if (user != null && server != null) {
- NavDrawer(
- destination = key,
- preferences = preferences,
- user = user,
- server = server,
- modifier = modifier,
- )
- } else {
- ErrorMessage("Trying to go to $key without a user logged in", null)
- }
+ val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle()
+ val backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle
+ Box(
+ modifier = modifier,
+ ) {
+ val baseBackgroundColor = MaterialTheme.colorScheme.background
+ if (backdrop.hasColors &&
+ (backdropStyle == BackdropStyle.BACKDROP_DYNAMIC_COLOR || backdropStyle == BackdropStyle.UNRECOGNIZED)
+ ) {
+ val animPrimary by animateColorAsState(
+ backdrop.primaryColor,
+ animationSpec = tween(1250),
+ label = "dynamic_backdrop_primary",
+ )
+ val animSecondary by animateColorAsState(
+ backdrop.secondaryColor,
+ animationSpec = tween(1250),
+ label = "dynamic_backdrop_secondary",
+ )
+ val animTertiary by animateColorAsState(
+ backdrop.tertiaryColor,
+ animationSpec = tween(1250),
+ label = "dynamic_backdrop_tertiary",
+ )
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .drawBehind {
+ drawRect(color = baseBackgroundColor)
+ // Top Left (Vibrant/Muted)
+ drawRect(
+ brush =
+ Brush.radialGradient(
+ colors = listOf(animSecondary, Color.Transparent),
+ center = Offset(0f, 0f),
+ radius = size.width * 0.8f,
+ ),
+ )
+ // Bottom Right (DarkVibrant/DarkMuted)
+ drawRect(
+ brush =
+ Brush.radialGradient(
+ colors = listOf(animPrimary, Color.Transparent),
+ center = Offset(size.width, size.height),
+ radius = size.width * 0.8f,
+ ),
+ )
+ // Bottom Left (Dark / Bridge)
+ drawRect(
+ brush =
+ Brush.radialGradient(
+ colors =
+ listOf(
+ baseBackgroundColor,
+ Color.Transparent,
+ ),
+ center = Offset(0f, size.height),
+ radius = size.width * 0.8f,
+ ),
+ )
+ // Top Right (Under Image - Vibrant/Bright)
+ drawRect(
+ brush =
+ Brush.radialGradient(
+ colors = listOf(animTertiary, Color.Transparent),
+ center = Offset(size.width, 0f),
+ radius = size.width * 0.8f,
+ ),
+ )
+ },
+ )
+ }
+ if (backdropStyle != BackdropStyle.BACKDROP_NONE) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ AsyncImage(
+ model =
+ ImageRequest
+ .Builder(LocalContext.current)
+ .data(backdrop.imageUrl)
+ .transitionFactory(CrossFadeFactory(800.milliseconds))
+ .build(),
+ contentDescription = null,
+ contentScale = ContentScale.Fit,
+ alignment = Alignment.TopEnd,
+ modifier =
+ Modifier
+ .align(Alignment.TopEnd)
+ .fillMaxHeight(.7f)
+ .fillMaxWidth(.7f)
+ .alpha(.95f)
+ .drawWithContent {
+ drawContent()
+ drawRect(
+ brush =
+ Brush.horizontalGradient(
+ colors = listOf(Color.Transparent, Color.Black),
+ startX = 0f,
+ endX = size.width * 0.6f,
+ ),
+ blendMode = BlendMode.DstIn,
+ )
+ drawRect(
+ brush =
+ Brush.verticalGradient(
+ colors = listOf(Color.Black, Color.Transparent),
+ startY = 0f,
+ endY = size.height,
+ ),
+ blendMode = BlendMode.DstIn,
+ )
+ },
+ )
}
- },
- )
+ }
+ NavDisplay(
+ backStack = navigationManager.backStack,
+ onBack = { navigationManager.goBack() },
+ entryDecorators =
+ listOf(
+ rememberSaveableStateHolderNavEntryDecorator(),
+ rememberViewModelStoreNavEntryDecorator(),
+ ),
+ entryProvider = { key ->
+ key as Destination
+ val contentKey = "${key}_${server?.id}_${user?.id}"
+ NavEntry(key, contentKey = contentKey) {
+ if (key.fullScreen) {
+ DestinationContent(
+ destination = key,
+ preferences = preferences,
+ onClearBackdrop = viewModel::clearBackdrop,
+ modifier = Modifier.fillMaxSize(),
+ )
+ } else if (user != null && server != null) {
+ NavDrawer(
+ destination = key,
+ preferences = preferences,
+ user = user,
+ server = server,
+ onClearBackdrop = viewModel::clearBackdrop,
+ modifier = Modifier.fillMaxSize(),
+ )
+ } else {
+ ErrorMessage("Trying to go to $key without a user logged in", null)
+ }
+ }
+ },
+ )
+ }
}
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 d2e748e1..182ec610 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
@@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.ui.nav
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.filter.DefaultForGenresFilterOptions
@@ -44,8 +45,12 @@ import timber.log.Timber
fun DestinationContent(
destination: Destination,
preferences: UserPreferences,
+ onClearBackdrop: () -> Unit,
modifier: Modifier = Modifier,
) {
+ if (destination.fullScreen) {
+ LaunchedEffect(Unit) { onClearBackdrop.invoke() }
+ }
when (destination) {
is Destination.Home -> {
HomePage(
@@ -125,6 +130,7 @@ fun DestinationContent(
}
BaseItemKind.BOX_SET -> {
+ LaunchedEffect(Unit) { onClearBackdrop.invoke() }
CollectionFolderBoxSet(
preferences = preferences,
itemId = destination.itemId,
@@ -136,6 +142,7 @@ fun DestinationContent(
}
BaseItemKind.PLAYLIST -> {
+ LaunchedEffect(Unit) { onClearBackdrop.invoke() }
PlaylistDetails(
destination = destination,
modifier = modifier,
@@ -143,6 +150,7 @@ fun DestinationContent(
}
BaseItemKind.COLLECTION_FOLDER -> {
+ LaunchedEffect(Unit) { onClearBackdrop.invoke() }
CollectionFolder(
preferences = preferences,
destination = destination,
@@ -154,6 +162,7 @@ fun DestinationContent(
}
BaseItemKind.FOLDER -> {
+ LaunchedEffect(Unit) { onClearBackdrop.invoke() }
CollectionFolder(
preferences = preferences,
destination = destination,
@@ -165,6 +174,7 @@ fun DestinationContent(
}
BaseItemKind.USER_VIEW -> {
+ LaunchedEffect(Unit) { onClearBackdrop.invoke() }
CollectionFolder(
preferences = preferences,
destination = destination,
@@ -176,6 +186,7 @@ fun DestinationContent(
}
BaseItemKind.PERSON -> {
+ LaunchedEffect(Unit) { onClearBackdrop.invoke() }
PersonPage(
preferences,
destination,
@@ -191,6 +202,7 @@ fun DestinationContent(
}
is Destination.FilteredCollection -> {
+ LaunchedEffect(Unit) { onClearBackdrop.invoke() }
CollectionFolderGeneric(
preferences = preferences,
itemId = destination.itemId,
@@ -204,6 +216,7 @@ fun DestinationContent(
}
is Destination.Recordings -> {
+ LaunchedEffect(Unit) { onClearBackdrop.invoke() }
CollectionFolderRecordings(
preferences,
destination.itemId,
@@ -213,6 +226,7 @@ fun DestinationContent(
}
is Destination.ItemGrid -> {
+ LaunchedEffect(Unit) { onClearBackdrop.invoke() }
ItemGrid(
destination,
modifier,
@@ -220,6 +234,7 @@ fun DestinationContent(
}
Destination.Favorites -> {
+ LaunchedEffect(Unit) { onClearBackdrop.invoke() }
FavoritesPage(
preferences = preferences,
modifier = modifier,
@@ -235,6 +250,7 @@ fun DestinationContent(
}
Destination.Search -> {
+ LaunchedEffect(Unit) { onClearBackdrop.invoke() }
SearchPage(
userPreferences = preferences,
modifier = 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 5247c327..60d2a758 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
@@ -77,6 +77,7 @@ 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.NavigationManager
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.components.TimeDisplay
@@ -105,6 +106,7 @@ class NavDrawerViewModel
constructor(
private val navDrawerItemRepository: NavDrawerItemRepository,
val navigationManager: NavigationManager,
+ val backdropService: BackdropService,
) : ViewModel() {
private var all: List? = null
val moreLibraries = MutableLiveData>(null)
@@ -217,6 +219,7 @@ fun NavDrawer(
preferences: UserPreferences,
user: JellyfinUser,
server: JellyfinServer,
+ onClearBackdrop: () -> Unit,
modifier: Modifier = Modifier,
viewModel: NavDrawerViewModel =
hiltViewModel(
@@ -314,11 +317,9 @@ fun NavDrawer(
val drawerPadding by animateDpAsState(if (drawerState.isOpen) 0.dp else 8.dp)
val drawerBackground by animateColorAsState(
if (drawerState.isOpen) {
- MaterialTheme.colorScheme.surfaceColorAtElevation(
- 1.dp,
- )
- } else {
MaterialTheme.colorScheme.surface
+ } else {
+ Color.Transparent
},
)
val spacedBy = 4.dp
@@ -542,6 +543,7 @@ fun NavDrawer(
DestinationContent(
destination = destination,
preferences = preferences,
+ onClearBackdrop = onClearBackdrop,
modifier =
Modifier
.fillMaxSize(),
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentPlayback.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentPlayback.kt
index 3295c686..9c62f17b 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentPlayback.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentPlayback.kt
@@ -6,6 +6,7 @@ import com.github.damontecres.wholphin.util.TrackSupport
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.TranscodingInfo
+import kotlin.time.Duration
data class CurrentPlayback(
val item: BaseItem,
@@ -18,4 +19,5 @@ data class CurrentPlayback(
val videoDecoder: String? = null,
val audioDecoder: String? = null,
val transcodeInfo: TranscodingInfo? = null,
+ val subtitleDelay: Duration = Duration.ZERO,
)
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 efb0c1fe..e145d380 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
@@ -11,14 +11,18 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
@@ -102,7 +106,8 @@ fun DownloadSubtitlesContent(
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
- val lang = rememberTextFieldState(language)
+// val lang = rememberTextFieldState(language)
+ var lang by rememberSaveable { mutableStateOf("") }
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -113,10 +118,17 @@ fun DownloadSubtitlesContent(
color = MaterialTheme.colorScheme.onSurface,
)
EditTextBox(
- state = lang,
- onKeyboardAction = {
- onSearch(lang.text.toString())
- },
+ value = lang,
+ onValueChange = { lang = it },
+ keyboardActions =
+ KeyboardActions(
+ onSearch = {
+ onSearch(lang)
+ },
+ ),
+ // onKeyboardAction = {
+// onSearch(lang.text.toString())
+// },
keyboardOptions =
KeyboardOptions(
imeAction = ImeAction.Search,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/NextUpEpisode.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/NextUpEpisode.kt
index 44048a40..f032c8b7 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/NextUpEpisode.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/NextUpEpisode.kt
@@ -98,7 +98,7 @@ fun NextUpEpisode(
modifier =
Modifier
.fillMaxHeight()
- .aspectRatio(aspectRatio)
+ .aspectRatio(aspectRatio.coerceAtLeast(AspectRatios.MIN))
// .fillMaxSize()
// .weight(1f)
.focusRequester(focusRequester),
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 1b3e97a4..f6a42f64 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
@@ -1,13 +1,26 @@
package com.github.damontecres.wholphin.ui.playback
import android.view.Gravity
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import androidx.compose.ui.window.DialogWindowProvider
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.TrackIndex
+import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import timber.log.Timber
+import kotlin.time.Duration
enum class PlaybackDialogType {
MORE,
@@ -16,6 +29,7 @@ enum class PlaybackDialogType {
AUDIO,
PLAYBACK_SPEED,
VIDEO_SCALE,
+ SUBTITLE_DELAY,
}
data class PlaybackSettings(
@@ -26,16 +40,19 @@ data class PlaybackSettings(
val subtitleStreams: List,
val playbackSpeed: Float,
val contentScale: ContentScale,
+ val subtitleDelay: Duration,
)
@Composable
fun PlaybackDialog(
+ enableSubtitleDelay: Boolean,
type: PlaybackDialogType,
settings: PlaybackSettings,
onDismissRequest: () -> Unit,
onControllerInteraction: () -> Unit,
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
onPlaybackActionClick: (PlaybackAction) -> Unit,
+ onChangeSubtitleDelay: (Duration) -> Unit,
) {
when (type) {
PlaybackDialogType.MORE -> {
@@ -97,11 +114,14 @@ fun PlaybackDialog(
PlaybackDialogType.SETTINGS -> {
val options =
- listOf(
- stringResource(R.string.audio),
- stringResource(R.string.playback_speed),
- stringResource(R.string.video_scale),
- )
+ buildList {
+ add(stringResource(R.string.audio))
+ add(stringResource(R.string.playback_speed))
+ add(stringResource(R.string.video_scale))
+ if (enableSubtitleDelay) {
+ add(stringResource(R.string.subtitle_delay))
+ }
+ }
BottomDialog(
choices = options,
currentChoice = null,
@@ -111,6 +131,7 @@ fun PlaybackDialog(
0 -> onClickPlaybackDialogType(PlaybackDialogType.AUDIO)
1 -> onClickPlaybackDialogType(PlaybackDialogType.PLAYBACK_SPEED)
2 -> onClickPlaybackDialogType(PlaybackDialogType.VIDEO_SCALE)
+ 3 -> onClickPlaybackDialogType(PlaybackDialogType.SUBTITLE_DELAY)
}
},
gravity = Gravity.END,
@@ -173,5 +194,31 @@ fun PlaybackDialog(
gravity = Gravity.END,
)
}
+
+ PlaybackDialogType.SUBTITLE_DELAY -> {
+ Dialog(
+ onDismissRequest = onDismissRequest,
+ properties = DialogProperties(usePlatformDefaultWidth = false),
+ ) {
+ val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider
+ dialogWindowProvider?.window?.setDimAmount(0f)
+
+ Box(
+ modifier =
+ Modifier
+ .wrapContentSize()
+ .background(
+ AppColors.TransparentBlack50,
+ shape = RoundedCornerShape(16.dp),
+ ),
+ ) {
+ SubtitleDelay(
+ delay = settings.subtitleDelay,
+ onChangeDelay = onChangeSubtitleDelay,
+ modifier = Modifier.padding(8.dp),
+ )
+ }
+ }
+ }
}
}
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 eefc176b..74821924 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
@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -34,6 +35,8 @@ 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.Brush
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
@@ -127,10 +130,37 @@ fun PlaybackOverlay(
var controllerHeight by remember { mutableStateOf(0.dp) }
var state by remember { mutableStateOf(OverlayViewState.CONTROLLER) }
+ // Background scrim for OSD readability
+ val scrimBrush =
+ remember {
+ Brush.verticalGradient(
+ colors =
+ listOf(
+ Color.Transparent,
+ Color.Black.copy(alpha = 0.5f),
+ Color.Black.copy(alpha = 0.80f),
+ ),
+ )
+ }
+
Box(
modifier = modifier,
contentAlignment = Alignment.BottomCenter,
) {
+ AnimatedVisibility(
+ visible = controllerViewState.controlsVisible,
+ enter = fadeIn(),
+ exit = fadeOut(),
+ modifier = Modifier.matchParentSize(),
+ ) {
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(scrimBrush),
+ )
+ }
+
AnimatedVisibility(
state == OverlayViewState.CONTROLLER,
enter = slideInVertically() + fadeIn(),
@@ -398,17 +428,13 @@ fun PlaybackOverlay(
val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight
val index =
(seekProgressMs / trickplayInfo.interval).toInt() / tilesPerImage
- val imageUrl = trickplayUrlFor(index)
+ val imageUrl = remember(index) { trickplayUrlFor(index) }
if (imageUrl != null) {
SeekPreviewImage(
- modifier =
- Modifier,
+ modifier = Modifier,
previewImageUrl = imageUrl,
- duration = playerControls.duration,
seekProgressMs = seekProgressMs,
- videoWidth = trickplayInfo.width,
- videoHeight = trickplayInfo.height,
trickPlayInfo = trickplayInfo,
)
}
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 4898103b..e27ea11f 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
@@ -79,11 +79,13 @@ import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.Media3SubtitleOverride
+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 java.util.UUID
+import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@@ -163,6 +165,11 @@ fun PlaybackPage(
var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) }
+ val subtitleDelay = currentPlayback?.subtitleDelay ?: Duration.ZERO
+ LaunchedEffect(subtitleDelay) {
+ (player as? MpvPlayer)?.subtitleDelay = subtitleDelay
+ }
+
val presentationState = rememberPresentationState(player, false)
val scaledModifier =
Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp)
@@ -325,37 +332,6 @@ fun PlaybackPage(
}
}
- // Subtitles
- if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) {
- val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f)
- AndroidView(
- factory = { context ->
- SubtitleView(context).apply {
- preferences.appPreferences.interfacePreferences.subtitlesPreferences.let {
- setStyle(it.toSubtitleStyle())
- setFixedTextSize(Dimension.SP, it.fontSize.toFloat())
- setBottomPaddingFraction(it.margin.toFloat() / 100f)
- }
- }
- },
- update = {
- it.setCues(cues)
- Media3SubtitleOverride(
- preferences.appPreferences.interfacePreferences.subtitlesPreferences
- .calculateEdgeSize(density),
- ).apply(it)
- },
- onReset = {
- it.setCues(null)
- },
- modifier =
- Modifier
- .fillMaxSize(maxSize)
- .align(Alignment.TopCenter)
- .background(Color.Transparent),
- )
- }
-
// The playback controls
AnimatedVisibility(
controllerViewState.controlsVisible,
@@ -395,6 +371,37 @@ fun PlaybackPage(
showClock = preferences.appPreferences.interfacePreferences.showClock,
)
}
+
+ // Subtitles
+ if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) {
+ val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f)
+ AndroidView(
+ factory = { context ->
+ SubtitleView(context).apply {
+ preferences.appPreferences.interfacePreferences.subtitlesPreferences.let {
+ setStyle(it.toSubtitleStyle())
+ setFixedTextSize(Dimension.SP, it.fontSize.toFloat())
+ setBottomPaddingFraction(it.margin.toFloat() / 100f)
+ }
+ }
+ },
+ update = {
+ it.setCues(cues)
+ Media3SubtitleOverride(
+ preferences.appPreferences.interfacePreferences.subtitlesPreferences
+ .calculateEdgeSize(density),
+ ).apply(it)
+ },
+ onReset = {
+ it.setCues(null)
+ },
+ modifier =
+ Modifier
+ .fillMaxSize(maxSize)
+ .align(Alignment.TopCenter)
+ .background(Color.Transparent),
+ )
+ }
}
// Ask to skip intros, etc button
@@ -473,9 +480,7 @@ fun PlaybackPage(
).joinToString(" - "),
description = it.data.overview,
imageUrl = LocalImageUrlService.current.rememberImageUrl(it),
- aspectRatio =
- it.data.primaryImageAspectRatio?.toFloat()
- ?: AspectRatios.WIDE,
+ aspectRatio = it.aspectRatio ?: AspectRatios.WIDE,
onClick = {
viewModel.reportInteraction()
viewModel.playNextUp()
@@ -545,6 +550,7 @@ fun PlaybackPage(
subtitleStreams = mediaInfo?.subtitleStreams.orEmpty(),
playbackSpeed = playbackSpeed,
contentScale = contentScale,
+ subtitleDelay = subtitleDelay,
),
onDismissRequest = {
playbackDialog = null
@@ -555,8 +561,16 @@ fun PlaybackPage(
onControllerInteraction = {
controllerViewState.pulseControls(Long.MAX_VALUE)
},
- onClickPlaybackDialogType = { playbackDialog = it },
+ onClickPlaybackDialogType = {
+ if (it == PlaybackDialogType.SUBTITLE_DELAY) {
+ // Hide controls so subtitles are fully visible
+ controllerViewState.hideControls()
+ }
+ playbackDialog = it
+ },
onPlaybackActionClick = onPlaybackActionClick,
+ onChangeSubtitleDelay = { viewModel.updateSubtitleDelay(it) },
+ enableSubtitleDelay = player is MpvPlayer,
)
}
}
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 2f08ee96..e462a312 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
@@ -21,6 +21,8 @@ import androidx.media3.exoplayer.DecoderCounters
import androidx.media3.exoplayer.DecoderReuseEvaluation
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.analytics.AnalyticsListener
+import coil3.imageLoader
+import coil3.request.ImageRequest
import com.github.damontecres.wholphin.data.ItemPlaybackDao
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
import com.github.damontecres.wholphin.data.ServerRepository
@@ -29,8 +31,6 @@ import com.github.damontecres.wholphin.data.model.Chapter
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.data.model.TrackIndex
-import com.github.damontecres.wholphin.data.model.chooseSource
-import com.github.damontecres.wholphin.data.model.chooseStream
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.ShowNextUpWhen
@@ -43,6 +43,7 @@ import com.github.damontecres.wholphin.services.PlayerFactory
import com.github.damontecres.wholphin.services.PlaylistCreationResult
import com.github.damontecres.wholphin.services.PlaylistCreator
import com.github.damontecres.wholphin.services.RefreshRateService
+import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.onMain
@@ -93,6 +94,7 @@ import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.PlaybackInfoDto
import org.jellyfin.sdk.model.api.PlaystateCommand
import org.jellyfin.sdk.model.api.PlaystateMessage
+import org.jellyfin.sdk.model.api.TrickplayInfo
import org.jellyfin.sdk.model.extensions.inWholeTicks
import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
@@ -100,6 +102,7 @@ import timber.log.Timber
import java.util.Date
import java.util.UUID
import javax.inject.Inject
+import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@@ -123,6 +126,7 @@ class PlaybackViewModel
private val deviceInfo: DeviceInfo,
private val deviceProfileService: DeviceProfileService,
private val refreshRateService: RefreshRateService,
+ val streamChoiceService: StreamChoiceService,
) : ViewModel(),
Player.Listener,
AnalyticsListener {
@@ -225,9 +229,13 @@ class PlaybackViewModel
"Error preparing for playback for $itemId",
),
) {
+ val destItem = (destination as? Destination.Playback)?.item?.data
val queriedItem =
- (destination as? Destination.Playback)?.item?.data
- ?: api.userLibraryApi.getItem(itemId).content
+ if (destItem?.mediaSources != null) {
+ destItem
+ } else {
+ api.userLibraryApi.getItem(itemId).content
+ }
val base =
if (queriedItem.type.playable) {
queriedItem
@@ -338,7 +346,8 @@ class PlaybackViewModel
}
}
}
- val mediaSource = chooseSource(base, playbackConfig)
+ val mediaSource = streamChoiceService.chooseSource(base, playbackConfig)
+ val plc = streamChoiceService.getPlaybackLanguageChoice(base)
if (mediaSource == null) {
showToast(
@@ -361,13 +370,27 @@ class PlaybackViewModel
?.sortedWith(compareBy { it.language }.thenByDescending { it.channels })
.orEmpty()
- val audioIndex =
- chooseStream(base, playbackConfig, MediaStreamType.AUDIO, preferences)
- ?.index
+ val audioStream =
+ streamChoiceService
+ .chooseAudioStream(
+ source = mediaSource,
+ seriesId = base.seriesId,
+ itemPlayback = playbackConfig,
+ plc = plc,
+ prefs = preferences,
+ )
+ val audioIndex = audioStream?.index
val subtitleIndex =
- chooseStream(base, playbackConfig, MediaStreamType.SUBTITLE, preferences)
- ?.index
+ streamChoiceService
+ .chooseSubtitleStream(
+ source = mediaSource,
+ audioStream = audioStream,
+ seriesId = base.seriesId,
+ itemPlayback = playbackConfig,
+ plc = plc,
+ prefs = preferences,
+ )?.index
Timber.d("Selected mediaSource=${mediaSource.id}, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex")
@@ -385,6 +408,17 @@ class PlaybackViewModel
?.get(mediaSource.id)
?.values
?.firstOrNull()
+ trickPlayInfo?.let { trickplayInfo ->
+ mediaSource.runTimeTicks?.ticks?.let { duration ->
+ viewModelScope.launchIO {
+ prefetchTrickplay(
+ duration,
+ trickplayInfo,
+ mediaSource.id?.toUUIDOrNull(),
+ )
+ }
+ }
+ }
val chapters = Chapter.fromDto(base, api)
withContext(Dispatchers.Main) {
@@ -481,7 +515,7 @@ class PlaybackViewModel
this@PlaybackViewModel.currentItemPlayback.value = itemPlayback
}
-
+ loadSubtitleDelay()
return@withContext
}
} else {
@@ -563,7 +597,7 @@ class PlaybackViewModel
else -> throw Exception("No supported playback method")
}
- Timber.v("Playback decision: $transcodeType")
+ Timber.i("Playback decision for $itemId: $transcodeType")
val externalSubtitleCount = source.externalSubtitlesCount
@@ -605,21 +639,6 @@ class PlaybackViewModel
liveStreamId = source.liveStreamId,
mediaSourceInfo = source,
)
- val itemPlayback =
- currentItemPlayback.copy(
- sourceId = source.id?.toUUIDOrNull(),
- audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED,
- subtitleIndex = subtitleIndex ?: TrackIndex.DISABLED,
- )
- if (userInitiated) {
- viewModelScope.launchIO {
- Timber.v("Saving user initiated item playback: %s", itemPlayback)
- val updated = itemPlaybackRepository.saveItemPlayback(itemPlayback)
- withContext(Dispatchers.Main) {
- this@PlaybackViewModel.currentItemPlayback.value = updated
- }
- }
- }
if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) {
source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }?.let {
@@ -638,14 +657,13 @@ class PlaybackViewModel
api = api,
player = player,
playback = playback,
- itemPlayback = itemPlayback,
+ itemPlayback = currentItemPlayback,
)
player.addListener(activityListener)
this@PlaybackViewModel.activityListener = activityListener
loading.value = LoadingState.Success
this@PlaybackViewModel.currentPlayback.update { playback }
- this@PlaybackViewModel.currentItemPlayback.value = itemPlayback
player.setMediaItem(
mediaItem,
positionMs,
@@ -668,6 +686,7 @@ class PlaybackViewModel
if (result.bothSelected) {
player.removeListener(this)
}
+ viewModelScope.launchIO { loadSubtitleDelay() }
}
}
}
@@ -679,41 +698,82 @@ class PlaybackViewModel
fun changeAudioStream(index: Int) {
viewModelScope.launchIO {
+ Timber.d("Changing audio track to %s", index)
+ val itemPlayback =
+ itemPlaybackRepository.saveTrackSelection(
+ item = item,
+ itemPlayback = currentItemPlayback.value!!,
+ trackIndex = index,
+ type = MediaStreamType.AUDIO,
+ )
+ this@PlaybackViewModel.currentItemPlayback.setValueOnMain(itemPlayback)
changeStreams(
item,
- currentItemPlayback.value!!,
+ itemPlayback,
index,
- currentItemPlayback.value?.subtitleIndex,
+ itemPlayback.subtitleIndex,
onMain { player.currentPosition },
true,
)
}
}
- fun changeSubtitleStream(index: Int?): Job =
+ fun changeSubtitleStream(index: Int): Job =
viewModelScope.launchIO {
+ Timber.d("Changing subtitle track to %s", index)
+ val itemPlayback =
+ itemPlaybackRepository.saveTrackSelection(
+ item = item,
+ itemPlayback = currentItemPlayback.value!!,
+ trackIndex = index,
+ type = MediaStreamType.SUBTITLE,
+ )
+ this@PlaybackViewModel.currentItemPlayback.setValueOnMain(itemPlayback)
changeStreams(
item,
- currentItemPlayback.value!!,
- currentItemPlayback.value?.audioIndex,
+ itemPlayback,
+ itemPlayback.audioIndex,
index,
onMain { player.currentPosition },
true,
)
}
- fun getTrickplayUrl(index: Int): String? {
- val itemId = item.id
- val mediaSourceId = currentItemPlayback.value?.sourceId
- val trickPlayInfo = currentMediaInfo.value?.trickPlayInfo ?: return null
- return api.trickplayApi.getTrickplayTileImageUrl(
- itemId,
- trickPlayInfo.width,
- index,
- mediaSourceId,
- )
+ private suspend fun prefetchTrickplay(
+ duration: Duration,
+ trickplayInfo: TrickplayInfo,
+ mediaSourceId: UUID?,
+ ) {
+ val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight
+ val totalCount =
+ (duration.inWholeMilliseconds / trickplayInfo.interval).toInt() / tilesPerImage + 1
+ (0..
+ delay(1500)
+ itemPlaybackRepository.saveTrackModifications(
+ item.itemId,
+ item.subtitleIndex,
+ newDelay,
+ )
+ }
+ }
+ result
+ }
+ }
+ }
+
+ suspend fun loadSubtitleDelay() {
+ currentItemPlayback.value?.let {
+ if (it.subtitleIndexEnabled) {
+ val result =
+ itemPlaybackRepository.getTrackModifications(it.itemId, it.subtitleIndex)
+ if (result != null) {
+ Timber.v(
+ "Loading subtitle delay %s for track=%s, itemId=%s",
+ result.delayMs,
+ it.subtitleIndex,
+ it.itemId,
+ )
+ currentPlayback.update { it?.copy(subtitleDelay = result.delayMs.milliseconds) }
+ }
+ }
+ }
+ }
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekPreviewImage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekPreviewImage.kt
index 1f060b15..08fb7685 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekPreviewImage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekPreviewImage.kt
@@ -1,24 +1,29 @@
package com.github.damontecres.wholphin.ui.playback
+import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.graphics.drawscope.scale
+import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
-import coil3.compose.AsyncImage
-import coil3.request.CachePolicy
+import coil3.compose.rememberAsyncImagePainter
import coil3.request.ImageRequest
-import coil3.request.transformations
-import com.github.damontecres.wholphin.ui.CoilTrickplayTransformation
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import org.jellyfin.sdk.model.api.TrickplayInfo
@@ -70,54 +75,70 @@ fun Modifier.offsetByPercent(xPercentage: Float) =
@Composable
fun SeekPreviewImage(
previewImageUrl: String,
- duration: Long,
seekProgressMs: Long,
- videoWidth: Int?,
- videoHeight: Int?,
trickPlayInfo: TrickplayInfo,
modifier: Modifier = Modifier,
- placeHolder: Painter? = null,
) {
val context = LocalContext.current
- if (previewImageUrl.isNotNullOrBlank() &&
- videoWidth != null &&
- videoHeight != null
- ) {
+ if (previewImageUrl.isNotNullOrBlank()) {
val height = 160.dp
- val width = height * (videoWidth.toFloat() / videoHeight)
- val heightPx = with(LocalDensity.current) { height.toPx().toInt() }
- val widthPx = with(LocalDensity.current) { width.toPx().toInt() }
+ val width = height * (trickPlayInfo.width.toFloat() / trickPlayInfo.height)
+ val scale = with(LocalDensity.current) { width.toPx() / trickPlayInfo.width }
- val index = (seekProgressMs.toDouble() / trickPlayInfo.interval).toInt() // Which tile
- val numberOfTitlesPerImage = trickPlayInfo.tileHeight * trickPlayInfo.tileWidth
- val imageIndex = index % numberOfTitlesPerImage
-
- AsyncImage(
- modifier =
- modifier
- .width(width)
- .height(height)
- .background(Color.Black)
- .border(1.5.dp, color = MaterialTheme.colorScheme.border),
- model =
+ val model =
+ remember(previewImageUrl) {
ImageRequest
.Builder(context)
.data(previewImageUrl)
- .memoryCachePolicy(CachePolicy.ENABLED)
- .transformations(
- CoilTrickplayTransformation(
- widthPx,
- heightPx,
- trickPlayInfo.tileHeight,
- trickPlayInfo.tileWidth,
- imageIndex,
- index,
- ),
- ).build(),
- contentScale = ContentScale.None,
- contentDescription = null,
- placeholder = placeHolder,
- )
+ .size(coil3.size.Size.ORIGINAL)
+ .build()
+ }
+ val painter =
+ rememberAsyncImagePainter(
+ model = model,
+ contentScale = ContentScale.None,
+ )
+ val index =
+ (seekProgressMs.toDouble() / trickPlayInfo.interval).toInt() // Index of tile across images
+ val numberOfTilesPerImage = trickPlayInfo.tileHeight * trickPlayInfo.tileWidth
+ val tileIndex =
+ index % numberOfTilesPerImage // Index of tile within the current image
+ val x = (tileIndex % trickPlayInfo.tileWidth) // x position within tile grid
+ val y = (tileIndex / trickPlayInfo.tileHeight) // y position
+ Box(
+ modifier =
+ modifier
+ .border(1.5.dp, color = MaterialTheme.colorScheme.border)
+ .background(Color.Black)
+ .height(height)
+ .width(width),
+ ) {
+ Canvas(
+ modifier =
+ Modifier
+ .height(height)
+ .width(width)
+ .clip(RectangleShape),
+ ) {
+ with(painter) {
+ // Scale and translate to the right position in the tile grid
+ scale(scale, scale, pivot = Offset.Zero) {
+ translate(
+ left = -x.toFloat() * trickPlayInfo.width,
+ top = -y.toFloat() * trickPlayInfo.height,
+ ) {
+ draw(
+ size =
+ Size(
+ trickPlayInfo.width * trickPlayInfo.tileWidth.toFloat(),
+ trickPlayInfo.height * trickPlayInfo.tileHeight.toFloat(),
+ ),
+ )
+ }
+ }
+ }
+ }
+ }
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SkipIndicator.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SkipIndicator.kt
index 0a3e343d..3e417a91 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SkipIndicator.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SkipIndicator.kt
@@ -61,6 +61,7 @@ fun SkipIndicator(
modifier = Modifier.align(Alignment.Center),
color = MaterialTheme.colorScheme.onBackground,
fontSize = 13.sp,
+ style = MaterialTheme.typography.bodySmall,
text = abs(durationMs / 1000).toString(),
)
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleDelay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleDelay.kt
new file mode 100644
index 00000000..17792899
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleDelay.kt
@@ -0,0 +1,109 @@
+package com.github.damontecres.wholphin.ui.playback
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.ClickableSurfaceDefaults
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.ui.PreviewTvSpec
+import com.github.damontecres.wholphin.ui.components.Button
+import com.github.damontecres.wholphin.ui.theme.WholphinTheme
+import com.github.damontecres.wholphin.ui.tryRequestFocus
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
+
+private val delayIncrements = listOf(50.milliseconds, 250.milliseconds, 1.seconds)
+
+@Composable
+fun SubtitleDelay(
+ delay: Duration,
+ onChangeDelay: (Duration) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val focusRequester = remember { FocusRequester() }
+ LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ modifier = modifier,
+ ) {
+ Text(
+ text = stringResource(R.string.subtitle_delay) + ": " + delay.toString(),
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ delayIncrements.reversed().forEach {
+ SubtitleDelayButton(
+ text = "-$it",
+ onClick = { onChangeDelay.invoke(-it) },
+ modifier = Modifier,
+ )
+ }
+ SubtitleDelayButton(
+ text = stringResource(R.string.reset),
+ onClick = { onChangeDelay.invoke(-delay) },
+ modifier = Modifier.focusRequester(focusRequester),
+ )
+ delayIncrements.forEach {
+ SubtitleDelayButton(
+ text = "+$it",
+ onClick = { onChangeDelay.invoke(it) },
+ modifier = Modifier,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun SubtitleDelayButton(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Button(
+ onClick = onClick,
+ modifier = modifier.width(64.dp),
+ shape =
+ ClickableSurfaceDefaults.shape(
+ shape = RoundedCornerShape(33),
+ ),
+ ) {
+ Text(
+ text = text,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+}
+
+@PreviewTvSpec
+@Composable
+private fun SubtitleDelayPreview() {
+ WholphinTheme {
+ SubtitleDelay(
+ delay = 1.5.seconds,
+ onChangeDelay = {},
+ modifier = Modifier,
+ )
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt
index 4ee2ded3..8568b286 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt
@@ -6,7 +6,6 @@ import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.TrackIndex
-import com.github.damontecres.wholphin.data.model.chooseSource
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.onMain
import com.github.damontecres.wholphin.ui.setValueOnMain
@@ -101,7 +100,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
api.userLibraryApi.getItem(itemId = it.itemId).content,
api,
)
- val mediaSource = chooseSource(item.data, it)
+ val mediaSource = streamChoiceService.chooseSource(item.data, it)
if (mediaSource == null) {
// This shouldn't happen, but just in case
showToast(
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 6f4ea0dd..d1e2baa2 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
@@ -15,6 +15,7 @@ import com.github.damontecres.wholphin.data.model.NavPinType
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.resetSubtitles
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
+import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs
import com.github.damontecres.wholphin.ui.launchIO
@@ -37,6 +38,7 @@ class PreferencesViewModel
private val api: ApiClient,
val preferenceDataStore: DataStore,
val navigationManager: NavigationManager,
+ val backdropService: BackdropService,
private val rememberTabManager: RememberTabManager,
private val serverRepository: ServerRepository,
private val navDrawerItemRepository: NavDrawerItemRepository,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/StringInputDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/StringInputDialog.kt
index 4229f78d..0e40ad24 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/StringInputDialog.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/StringInputDialog.kt
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -38,9 +39,14 @@ fun StringInputDialog(
elevation: Dp = 3.dp,
) {
val mutableValue = rememberTextFieldState(input.value ?: "")
+ var mutableText by remember { mutableStateOf(input.value ?: "") }
// var mutableValue by remember { mutableStateOf(input.value ?: "") }
val onDone = {
- onSave.invoke(mutableValue.text.toString())
+ if (input.maxLines > 1) {
+ onSave.invoke(mutableValue.text.toString())
+ } else {
+ onSave.invoke(mutableText)
+ }
}
var showConfirm by remember { mutableStateOf(false) }
Dialog(
@@ -79,17 +85,34 @@ fun StringInputDialog(
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier,
)
- EditTextBox(
- state = mutableValue,
- keyboardOptions = input.keyboardOptions,
- onKeyboardAction = {
- onDone.invoke()
- },
- leadingIcon = null,
- isInputValid = { true },
- maxLines = input.maxLines,
- modifier = Modifier.fillMaxWidth(),
- )
+ if (input.maxLines > 1) {
+ EditTextBox(
+ state = mutableValue,
+ keyboardOptions = input.keyboardOptions,
+ onKeyboardAction = {
+ onDone.invoke()
+ },
+ leadingIcon = null,
+ isInputValid = { true },
+ maxLines = input.maxLines,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ } else {
+ EditTextBox(
+ value = mutableText,
+ onValueChange = { mutableText = it },
+ keyboardOptions = input.keyboardOptions,
+ keyboardActions =
+ KeyboardActions(
+ onDone = {
+ onDone.invoke()
+ },
+ ),
+ leadingIcon = null,
+ isInputValid = { true },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
Row(
horizontalArrangement = Arrangement.SpaceAround,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt
index 08957981..0d7d3c6e 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt
@@ -1,8 +1,17 @@
package com.github.damontecres.wholphin.ui.setup
+import androidx.compose.foundation.BorderStroke
+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.fillMaxSize
+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.items
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
@@ -13,18 +22,26 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
+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
import androidx.compose.ui.window.DialogProperties
+import androidx.tv.material3.Border
+import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.Icon
import androidx.tv.material3.IconButtonDefaults
import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.JellyfinServer
+import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogPopup
@@ -157,3 +174,261 @@ fun ServerList(
)
}
}
+
+/**
+ * Generate a consistent color for a UUID
+ */
+@Composable
+fun rememberIdColor(
+ id: UUID?,
+ nullColor: Color = MaterialTheme.colorScheme.surfaceVariant,
+): Color =
+ remember(id) {
+ if (id == null) {
+ return@remember nullColor
+ }
+ // Generate a color based on the server ID hash, fallback to URL hash
+ val hash = id.hashCode()
+ val hue = (hash % 360).toFloat()
+ val saturation = 0.6f + ((hash / 360) % 40).toFloat() / 100f // 0.6-1.0
+ val brightness = 0.4f + ((hash / 14400) % 30).toFloat() / 100f // 0.4-0.7 (darker colors)
+
+ // Convert HSV to RGB
+ val c = brightness * saturation
+ val x = c * (1 - kotlin.math.abs((hue / 60f) % 2f - 1))
+ val m = brightness - c
+
+ val (r, g, b) =
+ when {
+ hue < 60 -> Triple(c, x, 0f)
+ hue < 120 -> Triple(x, c, 0f)
+ hue < 180 -> Triple(0f, c, x)
+ hue < 240 -> Triple(0f, x, c)
+ hue < 300 -> Triple(x, 0f, c)
+ else -> Triple(c, 0f, x)
+ }
+
+ Color(
+ red = (r + m).coerceIn(0f, 1f),
+ green = (g + m).coerceIn(0f, 1f),
+ blue = (b + m).coerceIn(0f, 1f),
+ )
+ }
+
+/**
+ * Server icon card component - displays a circular card with server name/letter
+ */
+@Composable
+fun ServerIconCard(
+ server: JellyfinServer,
+ connectionStatus: ServerConnectionStatus,
+ isCurrentServer: Boolean,
+ onClick: () -> Unit,
+ onLongClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ allowDelete: Boolean = false,
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+
+ // Generate unique color for this server
+ val serverColor = rememberIdColor(server.id)
+
+ // Card dimensions - circular card
+ val cardSize = Cards.serverUserCircle
+
+ val displayText =
+ remember(server) {
+ (server.name ?: server.url.replace(Regex("^https?://"), ""))
+ .firstOrNull()
+ ?.uppercase()
+ ?: "?"
+ }
+
+ Column(
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ ) {
+ // Circular card with colored background
+ Surface(
+ onClick = onClick,
+ onLongClick = if (allowDelete) onLongClick else null,
+ interactionSource = interactionSource,
+ modifier = Modifier.size(cardSize),
+ shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
+ colors =
+ ClickableSurfaceDefaults.colors(
+ containerColor =
+ if (isCurrentServer) {
+ serverColor.copy(alpha = 0.7f)
+ } else {
+ serverColor.copy(alpha = 0.5f)
+ },
+ focusedContainerColor =
+ if (isCurrentServer) {
+ serverColor.copy(alpha = 0.9f)
+ } else {
+ serverColor.copy(alpha = 0.7f)
+ },
+ ),
+ border =
+ ClickableSurfaceDefaults.border(
+ focusedBorder =
+ Border(
+ border =
+ BorderStroke(
+ width = 3.dp,
+ color = MaterialTheme.colorScheme.onSurface,
+ ),
+ shape = CircleShape,
+ ),
+ ),
+ scale = ClickableSurfaceDefaults.scale(focusedScale = 1.2f),
+ ) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ // Show connection status indicator or server name/letter
+ when (connectionStatus) {
+ is ServerConnectionStatus.Success -> {
+ // Show server name/letter
+
+ Text(
+ text = displayText,
+ style =
+ MaterialTheme.typography.displayLarge.copy(
+ fontWeight = FontWeight.Bold,
+ ),
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center,
+ )
+ }
+
+ ServerConnectionStatus.Pending -> {
+ CircularProgress(
+ modifier = Modifier.size(cardSize * 0.4f),
+ )
+ }
+
+ is ServerConnectionStatus.Error -> {
+ // Show warning icon with server letter below
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Default.Warning,
+ contentDescription = connectionStatus.message,
+ tint = MaterialTheme.colorScheme.errorContainer,
+ modifier = Modifier.size(cardSize * 0.3f),
+ )
+ Text(
+ text = displayText,
+ style =
+ MaterialTheme.typography.titleLarge.copy(
+ fontWeight = FontWeight.Bold,
+ ),
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+ }
+ }
+ }
+
+ // Server name below the card
+ Text(
+ text = server.name?.ifBlank { null } ?: server.url,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ modifier =
+ Modifier
+ .width(cardSize)
+ .padding(horizontal = 4.dp),
+ )
+ }
+}
+
+/**
+ * Add Server card component - displays a + icon in a circle
+ */
+@Composable
+fun AddServerCard(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+
+ // Use a neutral gray color for the add server card
+ val addServerColor = MaterialTheme.colorScheme.surfaceVariant
+
+ // Card dimensions - circular card (same as server cards)
+ val cardSize = Cards.height2x3 * 0.75f // ~120dp
+
+ Column(
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ ) {
+ // Circular card with colored background
+ Surface(
+ onClick = onClick,
+ interactionSource = interactionSource,
+ modifier = Modifier.size(cardSize),
+ shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
+ colors =
+ ClickableSurfaceDefaults.colors(
+ containerColor = addServerColor.copy(alpha = 0.4f),
+ focusedContainerColor = addServerColor.copy(alpha = 0.6f),
+ ),
+ border =
+ ClickableSurfaceDefaults.border(
+ focusedBorder =
+ Border(
+ border =
+ BorderStroke(
+ width = 3.dp,
+ color = MaterialTheme.colorScheme.onSurface,
+ ),
+ shape = CircleShape,
+ ),
+ ),
+ scale = ClickableSurfaceDefaults.scale(focusedScale = 1.2f),
+ ) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = stringResource(R.string.add_server),
+ tint = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.size(cardSize * 0.4f), // Size of the + icon
+ )
+ }
+ }
+
+ // "Add Server" text below the card
+ Text(
+ text = stringResource(R.string.add_server),
+ style =
+ MaterialTheme.typography.bodyLarge.copy(
+ fontWeight = FontWeight.Bold,
+ ),
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ modifier =
+ Modifier
+ .width(cardSize)
+ .padding(horizontal = 4.dp),
+ )
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt
index c1bc542e..4902ca06 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt
@@ -1,16 +1,23 @@
package com.github.damontecres.wholphin.ui.setup
-import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.layout.wrapContentWidth
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.foundation.text.input.rememberTextFieldState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -22,6 +29,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.focusRestorer
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
@@ -29,19 +38,21 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
-import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.CircularProgress
+import com.github.damontecres.wholphin.ui.components.DialogItem
+import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.EditTextBox
import com.github.damontecres.wholphin.ui.components.TextButton
import com.github.damontecres.wholphin.ui.dimAndBlur
+import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState
-import org.jellyfin.sdk.model.api.PublicSystemInfo
@Composable
fun SwitchServerContent(
@@ -51,141 +62,185 @@ fun SwitchServerContent(
val servers by viewModel.servers.observeAsState(listOf())
val serverStatus by viewModel.serverStatus.observeAsState(mapOf())
- val discoveredServers by viewModel.discoveredServers.observeAsState(listOf())
-
var showAddServer by remember { mutableStateOf(false) }
+ var showDeleteDialog by remember { mutableStateOf(null) }
LaunchedEffect(Unit) {
viewModel.init()
- viewModel.discoverServers()
}
Box(
- modifier = modifier.dimAndBlur(showAddServer),
+ modifier = modifier.dimAndBlur(showAddServer || showDeleteDialog != null),
) {
- Row(
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(24.dp),
modifier =
Modifier
+ .fillMaxWidth()
+ // Center the content like the Select User screen
.align(Alignment.Center)
- .padding(32.dp),
+ .padding(16.dp),
) {
+ // Match SwitchUser header height (title + subtitle) to align icons vertically across screens
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
- modifier =
- Modifier
- .fillMaxWidth()
- .weight(1f)
- .padding(16.dp)
- .background(
- MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
- shape = RoundedCornerShape(16.dp),
- ),
) {
Text(
text = stringResource(R.string.select_server),
style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.onSurface,
)
- ServerList(
- servers = servers,
- connectionStatus = serverStatus,
- onSwitchServer = {
- viewModel.switchServer(it)
- },
- onTestServer = {
- viewModel.testServer(it)
- },
- onAddServer = {
- showAddServer = true
- },
- onRemoveServer = {
- viewModel.removeServer(it)
- },
- allowAdd = true,
- allowDelete = true,
- modifier =
- Modifier
- .fillMaxWidth()
- .background(
- MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
- shape = RoundedCornerShape(16.dp),
- ),
+ // Invisible subtitle placeholder to mirror the server name line on the Select User screen
+ Text(
+ text = "Server placeholder",
+ style = MaterialTheme.typography.titleLarge,
+ color = Color.Transparent,
)
}
- // Discover
- Column(
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(8.dp),
+
+ // Horizontal scrollable list of server icons - centered
+ Box(
+ modifier = Modifier.fillMaxWidth(),
+ contentAlignment = Alignment.Center,
+ ) {
+ val focusRequester = remember { FocusRequester() }
+ val firstServerFocus = remember { FocusRequester() }
+ if (servers.isNotEmpty()) {
+ LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
+ }
+ LazyRow(
+ horizontalArrangement = Arrangement.spacedBy(24.dp),
+ contentPadding = PaddingValues(horizontal = 48.dp, vertical = 16.dp),
+ modifier =
+ Modifier
+ .wrapContentWidth()
+ .focusRestorer(firstServerFocus)
+ .focusRequester(focusRequester),
+ ) {
+ itemsIndexed(servers) { index, server ->
+ val status = serverStatus[server.id] ?: ServerConnectionStatus.Pending
+ ServerIconCard(
+ server = server,
+ connectionStatus = status,
+ isCurrentServer = false, // TODO: Determine current server if needed
+ onClick = {
+ when (status) {
+ is ServerConnectionStatus.Success -> {
+ viewModel.switchServer(server)
+ }
+
+ ServerConnectionStatus.Pending -> {
+ // Do nothing while pending
+ }
+
+ is ServerConnectionStatus.Error -> {
+ viewModel.testServer(server)
+ }
+ }
+ },
+ onLongClick = {
+ showDeleteDialog = server
+ },
+ allowDelete = true,
+ modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(firstServerFocus)),
+ )
+ }
+ // Add Server card - always rightmost
+ item {
+ AddServerCard(
+ onClick = { showAddServer = true },
+ )
+ }
+ }
+ }
+ // Non-focusable spacer to mirror the space occupied by the "Switch Servers" button
+ Spacer(
modifier =
Modifier
.fillMaxWidth()
- .weight(1f)
- .padding(16.dp)
- .background(
- MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
- shape = RoundedCornerShape(16.dp),
- ),
- ) {
- Text(
- text = stringResource(R.string.discovered_servers),
- style = MaterialTheme.typography.displaySmall,
- color = MaterialTheme.colorScheme.onSurface,
- )
- if (discoveredServers.isEmpty()) {
- Text(
- text = stringResource(R.string.searching),
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.onSurface,
- )
- } else {
- ServerList(
- servers = discoveredServers,
- connectionStatus =
- discoveredServers
- .map { it.id }
- .associateWith { ServerConnectionStatus.Success(PublicSystemInfo()) },
- onSwitchServer = {
- viewModel.addServer(it.url)
+ .height(56.dp),
+ // approximate TV button height
+ )
+ }
+
+ // Delete server dialog
+ showDeleteDialog?.let { server ->
+ DialogPopup(
+ showDialog = true,
+ title = server.name ?: server.url,
+ dialogItems =
+ listOf(
+ DialogItem(
+ stringResource(R.string.switch_servers),
+ R.string.fa_arrow_left_arrow_right,
+ ) {
+ viewModel.switchServer(server)
+ showDeleteDialog = null
},
- onTestServer = {
- viewModel.testServer(it)
+ DialogItem(
+ stringResource(R.string.delete),
+ Icons.Default.Delete,
+ Color.Red.copy(alpha = .8f),
+ ) {
+ viewModel.removeServer(server)
+ showDeleteDialog = null
},
- onAddServer = {},
- onRemoveServer = {},
- allowAdd = false,
- allowDelete = false,
- modifier =
- Modifier
- .fillMaxWidth()
- .background(
- MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
- shape = RoundedCornerShape(16.dp),
- ),
- )
- }
- }
+ ),
+ onDismissRequest = { showDeleteDialog = null },
+ dismissOnClick = true,
+ waitToLoad = true,
+ properties = DialogProperties(),
+ elevation = 5.dp,
+ )
}
if (showAddServer) {
+ var showEnterAddress by remember { mutableStateOf(false) }
+
LaunchedEffect(Unit) {
viewModel.clearAddServerState()
+ if (!showEnterAddress) {
+ viewModel.discoverServers()
+ }
}
- val state by viewModel.addServerState.observeAsState(LoadingState.Pending)
- val url = rememberTextFieldState()
- val submit = {
- viewModel.addServer(url.text.toString())
+
+ val discoveredServers by viewModel.discoveredServers.observeAsState(listOf())
+
+ // Filter out duplicates within the discovered servers list (same URL appearing multiple times)
+ val filteredDiscoveredServers =
+ remember(discoveredServers) {
+ val seenUrls = mutableSetOf()
+ discoveredServers.filter { server ->
+ val normalizedUrl = server.url.lowercase().trim()
+ if (normalizedUrl in seenUrls) {
+ false // Duplicate, filter it out
+ } else {
+ seenUrls.add(normalizedUrl)
+ true // First occurrence, keep it
+ }
+ }
+ }
+
+ val firstDiscoveredServerFocusRequester = remember { FocusRequester() }
+
+ // Default focus to first discovered server if available
+ LaunchedEffect(filteredDiscoveredServers.isNotEmpty(), showEnterAddress) {
+ if (!showEnterAddress && filteredDiscoveredServers.isNotEmpty()) {
+ firstDiscoveredServerFocusRequester.tryRequestFocus()
+ }
}
+
BasicDialog(
onDismissRequest = {
showAddServer = false
+ showEnterAddress = false
viewModel.clearAddServerState()
},
properties = DialogProperties(usePlatformDefaultWidth = false),
elevation = 10.dp,
) {
- val focusRequester = remember { FocusRequester() }
- LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -194,45 +249,134 @@ fun SwitchServerContent(
.padding(16.dp)
.fillMaxWidth(.4f),
) {
- Text(
- text = stringResource(R.string.enter_server_url),
- )
- EditTextBox(
- state = url,
- keyboardOptions =
- KeyboardOptions(
- capitalization = KeyboardCapitalization.None,
- autoCorrectEnabled = false,
- keyboardType = KeyboardType.Uri,
- imeAction = ImeAction.Go,
- ),
- onKeyboardAction = { submit.invoke() },
- modifier =
- Modifier
- .focusRequester(focusRequester)
- .fillMaxWidth(),
- )
- when (val st = state) {
- is LoadingState.Error -> {
+ if (!showEnterAddress) {
+ // Show discovered servers first
+ Text(
+ text = stringResource(R.string.discovered_servers),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+
+ if (filteredDiscoveredServers.isEmpty() && discoveredServers.isEmpty()) {
Text(
- text =
- st.message ?: st.exception?.localizedMessage
- ?: "An error occurred",
- color = MaterialTheme.colorScheme.error,
+ text = stringResource(R.string.searching),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
)
+ } else if (filteredDiscoveredServers.isEmpty()) {
+ Text(
+ text = stringResource(R.string.no_servers_found),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ } else {
+ LazyColumn(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .heightIn(max = 300.dp),
+ ) {
+ items(
+ filteredDiscoveredServers.size,
+ key = { filteredDiscoveredServers[it].url },
+ ) { index ->
+ val server = filteredDiscoveredServers[index]
+ val focusRequester =
+ if (index == 0) {
+ firstDiscoveredServerFocusRequester
+ } else {
+ remember { FocusRequester() }
+ }
+
+ ListItem(
+ enabled = true,
+ selected = false,
+ headlineContent = {
+ Text(
+ text = server.name?.ifBlank { null } ?: server.url,
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ },
+ supportingContent = {
+ Text(
+ text = server.url,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ },
+ onClick = {
+ viewModel.addServer(server.url)
+ },
+ modifier = Modifier.focusRequester(focusRequester),
+ )
+ }
+ }
}
- else -> {}
- }
- TextButton(
- onClick = { submit.invoke() },
- enabled = url.text.isNotNullOrBlank() && state == LoadingState.Pending,
- modifier = Modifier,
- ) {
- if (state == LoadingState.Loading) {
- CircularProgress(Modifier.size(32.dp))
- } else {
- Text(text = stringResource(R.string.submit))
+ TextButton(
+ onClick = {
+ showEnterAddress = true
+ },
+ modifier = Modifier.align(Alignment.CenterHorizontally),
+ ) {
+ Text(text = stringResource(R.string.enter_server_address))
+ }
+ } else {
+ // Show enter server address form
+ val state by viewModel.addServerState.observeAsState(LoadingState.Pending)
+ var url by remember { mutableStateOf("") }
+ val submit = {
+ viewModel.addServer(url)
+ }
+ val textBoxFocusRequester = remember { FocusRequester() }
+
+ LaunchedEffect(Unit) {
+ textBoxFocusRequester.tryRequestFocus()
+ }
+
+ Text(
+ text = stringResource(R.string.enter_server_url),
+ )
+ EditTextBox(
+ value = url,
+ onValueChange = { url = it },
+ keyboardOptions =
+ KeyboardOptions(
+ capitalization = KeyboardCapitalization.None,
+ autoCorrectEnabled = false,
+ keyboardType = KeyboardType.Uri,
+ imeAction = ImeAction.Go,
+ ),
+ keyboardActions =
+ KeyboardActions(
+ onGo = { submit.invoke() },
+ ),
+ modifier =
+ Modifier
+ .focusRequester(textBoxFocusRequester)
+ .fillMaxWidth(),
+ )
+ when (val st = state) {
+ is LoadingState.Error -> {
+ Text(
+ text =
+ st.message ?: st.exception?.localizedMessage
+ ?: "An error occurred",
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+
+ else -> {}
+ }
+ TextButton(
+ onClick = { submit.invoke() },
+ enabled = url.isNotNullOrBlank() && state == LoadingState.Pending,
+ modifier = Modifier,
+ ) {
+ if (state == LoadingState.Loading) {
+ CircularProgress(Modifier.size(32.dp))
+ } else {
+ Text(text = stringResource(R.string.submit))
+ }
}
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt
index df0b79ee..19c00f18 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt
@@ -1,7 +1,6 @@
package com.github.damontecres.wholphin.ui.setup
import android.widget.Toast
-import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -11,9 +10,8 @@ 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.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -36,7 +34,6 @@ import androidx.compose.ui.window.DialogProperties
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
-import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
@@ -94,27 +91,28 @@ fun SwitchUserContent(
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(24.dp),
modifier =
Modifier
- .fillMaxWidth(.5f)
+ .fillMaxWidth()
.align(Alignment.Center)
- .padding(16.dp)
- .background(
- MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
- shape = RoundedCornerShape(16.dp),
- ),
+ .padding(16.dp),
) {
- Text(
- text = stringResource(R.string.select_user),
- style = MaterialTheme.typography.displaySmall,
- color = MaterialTheme.colorScheme.onSurface,
- )
- Text(
- text = server.name ?: server.url,
- style = MaterialTheme.typography.titleLarge,
- color = MaterialTheme.colorScheme.onSurface,
- )
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.select_user),
+ style = MaterialTheme.typography.displaySmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ Text(
+ text = server.name ?: server.url,
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
UserList(
users = users,
currentUser = currentUser,
@@ -134,13 +132,7 @@ fun SwitchUserContent(
onSwitchServer = {
viewModel.navigationManager.navigateTo(Destination.ServerList)
},
- modifier =
- Modifier
- .fillMaxWidth()
- .background(
- MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
- shape = RoundedCornerShape(16.dp),
- ),
+ modifier = Modifier.fillMaxWidth(),
)
}
}
@@ -170,7 +162,7 @@ fun SwitchUserContent(
Modifier
.focusGroup()
.padding(16.dp)
- .fillMaxWidth(.66f),
+ .fillMaxWidth(.4f),
) {
if (useQuickConnect) {
if (quickConnect == null && userState !is LoadingState.Error) {
@@ -196,6 +188,8 @@ fun SwitchUserContent(
text = "Use Quick Connect on your device to authenticate to ${server.name ?: server.url}",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(),
)
Text(
text = quickConnect?.code ?: "Failed to get code",
@@ -215,13 +209,15 @@ fun SwitchUserContent(
modifier = Modifier.align(Alignment.CenterHorizontally),
)
} else {
- val username = rememberTextFieldState()
- val password = rememberTextFieldState()
+// val username = rememberTextFieldState()
+// val password = rememberTextFieldState()
+ var username by remember { mutableStateOf("") }
+ var password by remember { mutableStateOf("") }
val onSubmit = {
viewModel.login(
server,
- username.text.toString(),
- password.text.toString(),
+ username,
+ password,
)
}
val focusRequester = remember { FocusRequester() }
@@ -231,6 +227,8 @@ fun SwitchUserContent(
text = "Enter username/password to login to ${server.name ?: server.url}",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(),
)
UserStateError(userState)
Row(
@@ -242,7 +240,8 @@ fun SwitchUserContent(
modifier = Modifier.padding(end = 8.dp),
)
EditTextBox(
- state = username,
+ value = username,
+ onValueChange = { username = it },
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
@@ -250,9 +249,15 @@ fun SwitchUserContent(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next,
),
- onKeyboardAction = {
- passwordFocusRequester.tryRequestFocus()
- },
+ keyboardActions =
+ KeyboardActions(
+ onNext = {
+ passwordFocusRequester.tryRequestFocus()
+ },
+ ),
+ // onKeyboardAction = {
+// passwordFocusRequester.tryRequestFocus()
+// },
isInputValid = { userState !is LoadingState.Error },
modifier = Modifier.focusRequester(focusRequester),
)
@@ -265,12 +270,12 @@ fun SwitchUserContent(
text = "Password",
modifier = Modifier.padding(end = 8.dp),
)
- LaunchedEffect(password.text) {
+ LaunchedEffect(password) {
viewModel.clearSwitchUserState()
}
EditTextBox(
- isPassword = true,
- state = password,
+ value = password,
+ onValueChange = { password = it },
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
@@ -278,7 +283,10 @@ fun SwitchUserContent(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Go,
),
- onKeyboardAction = { onSubmit.invoke() },
+ keyboardActions =
+ KeyboardActions(
+ onGo = { onSubmit.invoke() },
+ ),
isInputValid = { userState !is LoadingState.Error },
modifier = Modifier.focusRequester(passwordFocusRequester),
)
@@ -286,7 +294,7 @@ fun SwitchUserContent(
TextButton(
stringRes = R.string.login,
onClick = { onSubmit.invoke() },
- enabled = username.text.isNotNullOrBlank() && password.text.isNotNullOrBlank(),
+ enabled = username.isNotNullOrBlank(),
modifier = Modifier.align(Alignment.CenterHorizontally),
)
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt
index 56d7ab98..7da64ca7 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt
@@ -7,6 +7,7 @@ import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
+import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.setValueOnMain
@@ -25,6 +26,7 @@ import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.HttpClientOptions
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
+import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.api.client.extensions.quickConnectApi
import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.api.client.extensions.userApi
@@ -41,6 +43,7 @@ class SwitchUserViewModel
val serverRepository: ServerRepository,
val serverDao: JellyfinServerDao,
val navigationManager: NavigationManager,
+ val imageUrlService: ImageUrlService,
@Assisted val server: JellyfinServer,
) : ViewModel() {
@AssistedFactory
@@ -50,7 +53,7 @@ class SwitchUserViewModel
val serverQuickConnect = MutableLiveData(false)
- val users = MutableLiveData>(listOf())
+ val users = MutableLiveData>(listOf())
val quickConnectState = MutableLiveData(null)
private var quickConnectJob: Job? = null
@@ -78,8 +81,7 @@ class SwitchUserViewModel
quickConnectJob?.cancel()
viewModelScope.launchIO {
users.setValueOnMain(listOf())
- val serverUsers =
- serverDao.getServer(server.id)?.users?.sortedBy { it.name } ?: listOf()
+ val serverUsers = getUsers()
withContext(Dispatchers.Main) {
users.setValueOnMain(serverUsers)
}
@@ -215,14 +217,23 @@ class SwitchUserViewModel
fun removeUser(user: JellyfinUser) {
viewModelScope.launchIO {
serverRepository.removeUser(user)
- val serverUsers =
- serverDao.getServer(user.serverId)?.users?.sortedBy { it.name } ?: listOf()
+ val serverUsers = getUsers()
withContext(Dispatchers.Main) {
users.value = serverUsers
}
}
}
+ private suspend fun getUsers(): List {
+ val api = jellyfin.createApi(server.url)
+ return serverDao
+ .getServer(server.id)
+ ?.users
+ ?.sortedBy { it.name }
+ ?.map { JellyfinUserAndImage(it, api.imageApi.getUserImageUrl(it.id)) }
+ .orEmpty()
+ }
+
private suspend fun setError(
msg: String? = null,
ex: Exception? = null,
@@ -231,3 +242,8 @@ class SwitchUserViewModel
switchUserState.value = LoadingState.Error(msg, ex)
}
}
+
+data class JellyfinUserAndImage(
+ val user: JellyfinUser,
+ val imageUrl: String?,
+)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt
index c4fb035e..d8b1b096 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt
@@ -1,37 +1,68 @@
package com.github.damontecres.wholphin.ui.setup
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.BorderStroke
+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.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.wrapContentWidth
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
-import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete
-import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.focusRestorer
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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
+import androidx.tv.material3.Border
+import androidx.tv.material3.Button
+import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.Icon
-import androidx.tv.material3.ListItem
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Surface
import androidx.tv.material3.Text
+import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.JellyfinUser
+import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogPopup
+import com.github.damontecres.wholphin.ui.isNotNullOrBlank
+import com.github.damontecres.wholphin.ui.tryRequestFocus
/**
* Display a list of users plus option to add a new one or switch servers
+ * Redesigned to match streaming service style with horizontal scrollable user icons
*/
@Composable
fun UserList(
- users: List,
+ users: List,
currentUser: JellyfinUser?,
onSwitchUser: (JellyfinUser) -> Unit,
onAddUser: () -> Unit,
@@ -41,59 +72,76 @@ fun UserList(
) {
var showDeleteDialog by remember { mutableStateOf(null) }
- LazyColumn(modifier = modifier) {
- items(users) { user ->
- ListItem(
- enabled = true,
- selected = user == currentUser,
- headlineContent = { Text(text = user.name ?: user.id.toString()) },
- leadingContent = {
- if (user.id == currentUser?.id) {
- Icon(
- imageVector = Icons.Default.Check,
- contentDescription = "current user",
- )
- }
- },
- onClick = { onSwitchUser.invoke(user) },
- onLongClick = {
- showDeleteDialog = user
- },
- modifier = Modifier,
- )
- }
- item {
- HorizontalDivider()
- ListItem(
- enabled = true,
- selected = false,
- headlineContent = { Text(text = stringResource(R.string.add_user)) },
- leadingContent = {
- Icon(
- imageVector = Icons.Default.Add,
- tint = Color.Green.copy(alpha = .8f),
- contentDescription = null,
+ Column(
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ ) {
+ // Horizontal scrollable list of user icons - centered
+ Box(
+ modifier = Modifier.fillMaxWidth(),
+ contentAlignment = Alignment.Center,
+ ) {
+ val focusRequester = remember { FocusRequester() }
+ val firstFocusRequester = remember { FocusRequester() }
+ if (users.isNotEmpty()) {
+ LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
+ }
+ LazyRow(
+ horizontalArrangement = Arrangement.spacedBy(24.dp), // Spacing to accommodate 20% scale
+ contentPadding = PaddingValues(horizontal = 48.dp, vertical = 16.dp), // Increased padding to accommodate 20% scale
+ modifier =
+ Modifier
+ .wrapContentWidth()
+ .focusRestorer(firstFocusRequester)
+ .focusRequester(focusRequester),
+ ) {
+ itemsIndexed(users) { index, user ->
+ UserIconCard(
+ user = user,
+ isCurrentUser = user.user.id == currentUser?.id,
+ onClick = { onSwitchUser.invoke(user.user) },
+ onLongClick = { showDeleteDialog = user.user },
+ modifier = if (index == 0) Modifier.focusRequester(firstFocusRequester) else Modifier,
)
- },
- onClick = { onAddUser.invoke() },
- modifier = Modifier,
- )
+ }
+ // Add User card - always rightmost
+ item {
+ AddUserCard(
+ onClick = { onAddUser.invoke() },
+ )
+ }
+ }
}
- item {
- HorizontalDivider()
- ListItem(
- enabled = true,
- selected = false,
- headlineContent = { Text(text = stringResource(R.string.switch_servers)) },
- leadingContent = {
+
+ // Switch servers button below user list - centered
+ Row(
+ horizontalArrangement = Arrangement.Center,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp),
+ ) {
+ Button(
+ onClick = { onSwitchServer.invoke() },
+ modifier = Modifier.width(200.dp), // Fixed width for consistency
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
Text(
text = stringResource(R.string.fa_arrow_left_arrow_right),
fontFamily = FontAwesome,
+ modifier = Modifier.padding(end = 8.dp),
)
- },
- onClick = { onSwitchServer.invoke() },
- modifier = Modifier,
- )
+ Text(
+ text = stringResource(R.string.switch_servers),
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
}
}
showDeleteDialog?.let { user ->
@@ -124,3 +172,193 @@ fun UserList(
)
}
}
+
+@Composable
+private fun UserIconCard(
+ user: JellyfinUserAndImage,
+ isCurrentUser: Boolean,
+ onClick: () -> Unit,
+ onLongClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ // Generate unique color for this user
+ val userColor = rememberIdColor(user.user.id)
+
+ // Track image loading errors
+ var imageError by remember { mutableStateOf(false) }
+
+ // Card dimensions - circular card
+ val cardSize = Cards.serverUserCircle
+
+ Column(
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ ) {
+ // Circular card with colored background
+ Surface(
+ onClick = onClick,
+ onLongClick = onLongClick,
+ interactionSource = interactionSource,
+ modifier = Modifier.size(cardSize),
+ shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
+ colors =
+ ClickableSurfaceDefaults.colors(
+ containerColor =
+ if (isCurrentUser) {
+ userColor.copy(alpha = 0.7f)
+ } else {
+ userColor.copy(alpha = 0.5f)
+ },
+ focusedContainerColor =
+ if (isCurrentUser) {
+ userColor.copy(alpha = 0.9f)
+ } else {
+ userColor.copy(alpha = 0.7f)
+ },
+ ),
+ border =
+ ClickableSurfaceDefaults.border(
+ focusedBorder =
+ Border(
+ border =
+ BorderStroke(
+ width = 3.dp,
+ color = MaterialTheme.colorScheme.onSurface,
+ ),
+ shape = CircleShape,
+ ),
+ ),
+ scale = ClickableSurfaceDefaults.scale(focusedScale = 1.2f),
+ ) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (user.imageUrl.isNotNullOrBlank() && !imageError) {
+ AsyncImage(
+ model = user.imageUrl,
+ contentDescription = user.user.name,
+ contentScale = ContentScale.Crop,
+ onError = { imageError = true },
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .clip(CircleShape),
+ )
+ } else {
+ // Show big bold first letter of username
+ val firstLetter =
+ remember(user) {
+ user.user.let {
+ (it.name ?: it.id.toString()).firstOrNull()?.uppercase()
+ } ?: "?"
+ }
+ Text(
+ text = firstLetter,
+ style =
+ MaterialTheme.typography.displayLarge.copy(
+ fontWeight = FontWeight.Bold,
+ ),
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+ }
+
+ // Username below the card
+ Text(
+ text = user.user.name ?: user.user.id.toString(),
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ modifier =
+ Modifier
+ .width(cardSize)
+ .padding(horizontal = 4.dp),
+ )
+ }
+}
+
+/**
+ * Add User card component - displays a + icon in a circle
+ */
+@Composable
+private fun AddUserCard(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+
+ // Use a neutral gray color for the add user card
+ val addUserColor = MaterialTheme.colorScheme.surfaceVariant
+
+ // Card dimensions - circular card (same as user cards)
+ val cardSize = Cards.height2x3 * 0.75f // ~120dp
+
+ Column(
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(24.dp), // Increased to accommodate 20% scale
+ ) {
+ // Circular card with colored background
+ Surface(
+ onClick = onClick,
+ interactionSource = interactionSource,
+ modifier = Modifier.size(cardSize),
+ shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
+ colors =
+ ClickableSurfaceDefaults.colors(
+ containerColor = addUserColor.copy(alpha = 0.4f),
+ focusedContainerColor = addUserColor.copy(alpha = 0.6f),
+ ),
+ border =
+ ClickableSurfaceDefaults.border(
+ focusedBorder =
+ Border(
+ border =
+ BorderStroke(
+ width = 3.dp,
+ color = MaterialTheme.colorScheme.onSurface,
+ ),
+ shape = CircleShape,
+ ),
+ ),
+ scale = ClickableSurfaceDefaults.scale(focusedScale = 1.2f),
+ ) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = stringResource(R.string.add_user),
+ tint = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.size(cardSize * 0.4f), // Size of the + icon
+ )
+ }
+ }
+
+ // "Add User" text below the card
+ Text(
+ text = stringResource(R.string.add_user),
+ style =
+ MaterialTheme.typography.bodyLarge.copy(
+ fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
+ ),
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ modifier =
+ Modifier
+ .width(cardSize)
+ .padding(horizontal = 4.dp),
+ )
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/Theme.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/Theme.kt
index 112d8817..53c10bd2 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/Theme.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/Theme.kt
@@ -15,22 +15,24 @@ import com.github.damontecres.wholphin.ui.theme.colors.PurpleThemeColors
val LocalTheme =
compositionLocalOf { AppThemeColors.PURPLE }
+fun getThemeColors(appThemeColors: AppThemeColors): ThemeColors =
+ when (appThemeColors) {
+ AppThemeColors.PURPLE -> PurpleThemeColors
+ AppThemeColors.BLUE -> BlueThemeColors
+ AppThemeColors.GREEN -> GreenThemeColors
+ AppThemeColors.ORANGE -> OrangeThemeColors
+ AppThemeColors.OLED_BLACK -> OledThemeColors
+ AppThemeColors.BOLD_BLUE -> BoldBlueThemeColors
+ AppThemeColors.UNRECOGNIZED -> PurpleThemeColors
+ }
+
@Composable
fun WholphinTheme(
darkTheme: Boolean = true,
appThemeColors: AppThemeColors = AppThemeColors.PURPLE,
content: @Composable () -> Unit,
) {
- val themeColors =
- when (appThemeColors) {
- AppThemeColors.PURPLE -> PurpleThemeColors
- AppThemeColors.BLUE -> BlueThemeColors
- AppThemeColors.GREEN -> GreenThemeColors
- AppThemeColors.ORANGE -> OrangeThemeColors
- AppThemeColors.OLED_BLACK -> OledThemeColors
- AppThemeColors.BOLD_BLUE -> BoldBlueThemeColors
- AppThemeColors.UNRECOGNIZED -> PurpleThemeColors
- }
+ val themeColors = getThemeColors(appThemeColors)
val colorScheme =
when {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt
new file mode 100644
index 00000000..53cd4d9b
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt
@@ -0,0 +1,42 @@
+package com.github.damontecres.wholphin.ui.util
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.compositionLocalOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import com.github.damontecres.wholphin.ui.TimeFormatter
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import java.time.LocalDateTime
+
+val LocalClock = compositionLocalOf { throw IllegalStateException() }
+
+/**
+ * Represents the current time
+ */
+data class Clock(
+ /**
+ * The current [LocalDateTime]
+ */
+ val now: LocalDateTime,
+ /**
+ * The current time formatted as a string with [TimeFormatter]
+ */
+ val timeString: String,
+)
+
+@Composable
+fun ProvideLocalClock(content: @Composable () -> Unit) {
+ var clock by remember { mutableStateOf(LocalDateTime.now().let { Clock(it, TimeFormatter.format(it)) }) }
+ LaunchedEffect(Unit) {
+ while (isActive) {
+ clock = LocalDateTime.now().let { Clock(it, TimeFormatter.format(it)) }
+ delay(1_000)
+ }
+ }
+ CompositionLocalProvider(LocalClock provides clock, content)
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt
index 0ed74fe0..b03ce9f9 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt
@@ -66,7 +66,7 @@ class ApiRequestPager(
suspend fun init(initialPosition: Int = 0): ApiRequestPager {
if (totalCount < 0) {
- fetchPage(initialPosition, true).join()
+ fetchPageBlocking(initialPosition, true)
}
return this
}
@@ -75,7 +75,7 @@ class ApiRequestPager(
if (index in 0..(
if (index in 0..(
override val size: Int
get() = totalCount
- private fun fetchPage(
+ private fun fetchPage(position: Int): Job =
+ scope.launch(ExceptionHandler() + Dispatchers.IO) {
+ fetchPageBlocking(position, false)
+ }
+
+ private suspend fun fetchPageBlocking(
position: Int,
setTotalCount: Boolean,
- ): Job =
- scope.launch(ExceptionHandler() + Dispatchers.IO) {
- mutex.withLock {
- val pageNumber = position / pageSize
- if (cachedPages.getIfPresent(pageNumber) == null) {
- if (DEBUG) Timber.v("fetchPage: $pageNumber")
- val newRequest =
- requestHandler.prepare(
- request,
- pageNumber * pageSize,
- pageSize,
- setTotalCount,
- )
- val result = requestHandler.execute(api, newRequest).content
- if (setTotalCount) {
- totalCount = result.totalRecordCount
- }
- val data = mutableListOf()
- result.items.forEach { data.add(BaseItem.from(it, api, useSeriesForPrimary)) }
- cachedPages.put(pageNumber, data)
- items = ItemList(totalCount, pageSize, cachedPages.asMap())
+ ) {
+ mutex.withLock {
+ val pageNumber = position / pageSize
+ if (cachedPages.getIfPresent(pageNumber) == null || setTotalCount) {
+ if (DEBUG) Timber.v("fetchPage: $pageNumber")
+ val newRequest =
+ requestHandler.prepare(
+ request,
+ pageNumber * pageSize,
+ pageSize,
+ setTotalCount,
+ )
+ val result = requestHandler.execute(api, newRequest).content
+ if (setTotalCount) {
+ totalCount = result.totalRecordCount.coerceAtLeast(0)
}
+ val data = mutableListOf()
+ result.items.forEach { data.add(BaseItem.from(it, api, useSeriesForPrimary)) }
+ cachedPages.put(pageNumber, data)
+ items = ItemList(totalCount, pageSize, cachedPages.asMap())
}
}
+ }
suspend fun refreshItem(
position: Int,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvLogger.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvLogger.kt
new file mode 100644
index 00000000..300d36f0
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvLogger.kt
@@ -0,0 +1,21 @@
+package com.github.damontecres.wholphin.util.mpv
+
+import timber.log.Timber
+
+class MpvLogger : MPVLib.LogObserver {
+ override fun logMessage(
+ prefix: String,
+ level: Int,
+ text: String,
+ ) {
+ // https://github.com/mpv-player/mpv/blob/122abdfec3124bfc92a2918a70ca8150eee68338/include/mpv/client.h#L1423
+ when {
+ level <= 10 -> Timber.wtf("%s, %s", prefix, text)
+ level <= 20 -> Timber.e("%s, %s", prefix, text)
+ level <= 30 -> Timber.w("%s, %s", prefix, text)
+ level <= 40 -> Timber.i("%s, %s", prefix, text)
+ level <= 50 -> Timber.d("%s, %s", prefix, text)
+ else -> Timber.v("%s, %s", prefix, text)
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt
index d1bfd492..a31638c4 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt
@@ -3,7 +3,10 @@ package com.github.damontecres.wholphin.util.mpv
import android.content.Context
import android.os.Build
import android.os.Handler
+import android.os.HandlerThread
import android.os.Looper
+import android.os.Message
+import android.os.Process
import android.view.Surface
import android.view.SurfaceHolder
import android.view.SurfaceView
@@ -35,6 +38,7 @@ import androidx.media3.common.util.Util
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.exoplayer.trackselection.TrackSelector
import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter
+import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEndFileReason.MPV_END_FILE_REASON_EOF
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEndFileReason.MPV_END_FILE_REASON_ERROR
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEndFileReason.MPV_END_FILE_REASON_STOP
@@ -42,9 +46,13 @@ import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_AUDIO_
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_END_FILE
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_FILE_LOADED
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_PLAYBACK_RESTART
+import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_START_FILE
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_VIDEO_RECONFIG
import timber.log.Timber
+import kotlin.concurrent.atomics.AtomicReference
import kotlin.concurrent.atomics.ExperimentalAtomicApi
+import kotlin.concurrent.atomics.update
+import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
@@ -56,20 +64,22 @@ import kotlin.time.Duration.Companion.seconds
@OptIn(UnstableApi::class)
class MpvPlayer(
private val context: Context,
- enableHardwareDecoding: Boolean,
- useGpuNext: Boolean,
+ private val enableHardwareDecoding: Boolean,
+ private val useGpuNext: Boolean,
) : BasePlayer(),
MPVLib.EventObserver,
- TrackSelector.InvalidationListener {
+ TrackSelector.InvalidationListener,
+ Handler.Callback {
companion object {
private const val DEBUG = false
}
- private var isPaused: Boolean = true
private var surface: Surface? = null
+ private val playbackState = AtomicReference(PlaybackState.EMPTY)
+ // This looper will sent events to the main thread
private val looper = Util.getCurrentOrMainLooper()
- private val handler = Handler(looper)
+ private val mainHandler = Handler(looper)
private val listeners =
ListenerSet(
looper,
@@ -80,19 +90,16 @@ class MpvPlayer(
private val availableCommands: Player.Commands
private val trackSelector = DefaultTrackSelector(context)
- private var mediaItem: MediaItem? = null
- private var startPositionMs: Long = 0L
- private var durationMs: Long = 0L
- private var positionMs: Long = -1L
- private var playbackState: Int = STATE_READY
+ // This thread/looper will receive commands from the main thread to execute
+ private val thread: HandlerThread =
+ HandlerThread("MpvPlayer:Playback", Process.THREAD_PRIORITY_AUDIO)
+ .also { it.start() }
+ private val internalHandler: Handler = Handler(thread.looper, this)
@Volatile
var isReleased = false
private set
- @Volatile
- private var isLoadingFile = false
-
init {
Timber.v("config-dir=${context.filesDir.path}")
MPVLib.create(context)
@@ -119,8 +126,11 @@ class MpvPlayer(
MPVLib.setOptionString("idle", "yes")
// MPVLib.setOptionString("sub-fonts-dir", File(context.filesDir, "fonts").absolutePath)
- MPVLib.addObserver(this)
- MPVProperty.observedProperties.forEach(MPVLib::observeProperty)
+ internalHandler.post {
+ MPVLib.addObserver(this@MpvPlayer)
+ MPVProperty.observedProperties.forEach(MPVLib::observeProperty)
+ MPVLib.addLogObserver(MpvLogger())
+ }
availableCommands =
Player.Commands
@@ -166,13 +176,11 @@ class MpvPlayer(
mediaItems: List,
resetPosition: Boolean,
) {
- throwIfReleased()
-
if (DEBUG) Timber.v("setMediaItems")
+ throwIfReleased()
mediaItems.firstOrNull()?.let {
- mediaItem = it
if (surface != null) {
- loadFile(it)
+ sendCommand(MpvCommand.LOAD_FILE, MediaAndPosition(it, C.TIME_UNSET))
}
}
}
@@ -183,8 +191,11 @@ class MpvPlayer(
startPositionMs: Long,
) {
if (DEBUG) Timber.v("setMediaItems")
- this.startPositionMs = startPositionMs
- setMediaItems(mediaItems.subList(startIndex, mediaItems.size), false)
+ throwIfReleased()
+ sendCommand(
+ MpvCommand.LOAD_FILE,
+ MediaAndPosition(mediaItems[startIndex], startPositionMs),
+ )
}
override fun addMediaItems(
@@ -213,17 +224,19 @@ class MpvPlayer(
override fun prepare() {
if (DEBUG) Timber.v("prepare")
- durationMs = 0L
- positionMs = -1L
- playbackState = STATE_READY
+ playbackState.update {
+ it.copy(
+ state = Player.STATE_READY,
+ )
+ }
}
override fun getPlaybackState(): Int {
if (DEBUG) Timber.v("getPlaybackState")
- return playbackState
+ return playbackState.load().state
}
- override fun getPlaybackSuppressionReason(): Int = Player.PLAYBACK_SUPPRESSION_REASON_NONE
+ override fun getPlaybackSuppressionReason(): Int = PLAYBACK_SUPPRESSION_REASON_NONE
override fun getPlayerError(): PlaybackException? {
TODO("Not yet implemented")
@@ -231,32 +244,21 @@ class MpvPlayer(
override fun setPlayWhenReady(playWhenReady: Boolean) {
if (isReleased) return
- if (DEBUG) Timber.v("setPlayWhenReady")
- if (playWhenReady) {
- MPVLib.setPropertyBoolean("pause", false)
- } else {
- MPVLib.setPropertyBoolean("pause", true)
- }
- notifyListeners(EVENT_PLAY_WHEN_READY_CHANGED) {
- onPlayWhenReadyChanged(
- playWhenReady,
- PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST,
- )
- }
+ if (DEBUG) Timber.v("setPlayWhenReady: $playWhenReady")
+ sendCommand(MpvCommand.PLAY_PAUSE, playWhenReady)
}
override fun getPlayWhenReady(): Boolean {
if (DEBUG) Timber.v("getPlayWhenReady")
if (isReleased) return false
- val isPaused = MPVLib.getPropertyBoolean("pause") ?: this.isPaused
- return !isPaused
+ return !playbackState.load().isPaused
}
override fun setRepeatMode(repeatMode: Int) {
if (DEBUG) Timber.v("setRepeatMode")
}
- override fun getRepeatMode(): Int = Player.REPEAT_MODE_OFF
+ override fun getRepeatMode(): Int = REPEAT_MODE_OFF
override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) {
if (DEBUG) Timber.v("setShuffleModeEnabled")
@@ -264,7 +266,7 @@ class MpvPlayer(
override fun getShuffleModeEnabled(): Boolean = false
- override fun isLoading(): Boolean = isLoadingFile
+ override fun isLoading(): Boolean = playbackState.load().isLoadingFile
override fun getSeekBackIncrement(): Long = 10_000
@@ -274,30 +276,34 @@ class MpvPlayer(
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) {
if (DEBUG) Timber.v("setPlaybackParameters")
- MPVLib.setPropertyDouble("speed", playbackParameters.speed.toDouble())
+ sendCommand(MpvCommand.SET_SPEED, playbackParameters.speed)
}
override fun getPlaybackParameters(): PlaybackParameters {
if (DEBUG) Timber.v("getPlaybackParameters")
- val speed = MPVLib.getPropertyDouble("speed")?.toFloat() ?: 1f
- return PlaybackParameters(speed)
+ return PlaybackParameters(playbackState.load().speed)
}
override fun stop() {
if (DEBUG) Timber.v("stop")
if (isReleased) return
pause()
- mediaItem = null
- positionMs = -1L
- durationMs = 0L
- playbackState = STATE_IDLE
+ internalHandler.removeCallbacks(updatePlaybackState)
+ playbackState.update {
+ PlaybackState.EMPTY
+ }
notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(false) }
notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { onPlaybackStateChanged(STATE_IDLE) }
}
override fun release() {
Timber.i("release")
+ internalHandler.removeCallbacks(updatePlaybackState)
+ playbackState.update {
+ PlaybackState.EMPTY
+ }
if (!isReleased) {
+ thread.quit()
MPVLib.removeObserver(this)
clearVideoSurfaceView(null)
MPVLib.destroy()
@@ -308,7 +314,7 @@ class MpvPlayer(
override fun getCurrentTracks(): Tracks {
if (DEBUG) Timber.v("getCurrentTracks")
if (isReleased) return Tracks.EMPTY
- return getTracks()
+ return playbackState.load().tracks
}
override fun getTrackSelectionParameters(): TrackSelectionParameters {
@@ -322,16 +328,16 @@ class MpvPlayer(
override fun setTrackSelectionParameters(parameters: TrackSelectionParameters) {
Timber.v("TrackSelection: setTrackSelectionParameters %s", parameters)
if (isReleased) return
- val tracks = getTracks()
+ val tracks = playbackState.load().tracks
if (C.TRACK_TYPE_TEXT in parameters.disabledTrackTypes) {
// Subtitles disabled
Timber.v("TrackSelection: disabling subtitles")
- MPVLib.setPropertyString("sid", "no")
+ sendCommand(MpvCommand.SET_TRACK_SELECTION, TrackSelection("sid", "no"))
}
if (C.TRACK_TYPE_AUDIO in parameters.disabledTrackTypes) {
// Audio disabled
Timber.v("TrackSelection: disabling audio")
- MPVLib.setPropertyString("aid", "no")
+ sendCommand(MpvCommand.SET_TRACK_SELECTION, TrackSelection("aid", "no"))
}
Timber.v("TrackSelection: Got ${parameters.overrides.size} overrides")
parameters.overrides.forEach { (trackGroup, trackSelectionOverride) ->
@@ -349,7 +355,10 @@ class MpvPlayer(
}
Timber.v("TrackSelection: activating %s %s '%s'", propertyName, trackId, id)
if (trackId != null && propertyName != null) {
- MPVLib.setPropertyString(propertyName, trackId)
+ sendCommand(
+ MpvCommand.SET_TRACK_SELECTION,
+ TrackSelection(propertyName, trackId),
+ )
true
} else {
false
@@ -396,24 +405,13 @@ class MpvPlayer(
override fun getDuration(): Long {
if (DEBUG) Timber.v("getDuration")
- if (isReleased) {
- return durationMs
- }
- val duration =
- MPVLib.getPropertyDouble("duration/full")?.seconds?.inWholeMilliseconds
- ?: durationMs
- return duration
+ return playbackState.load().durationMs
}
override fun getCurrentPosition(): Long {
if (DEBUG) Timber.v("getCurrentPosition")
- if (isReleased) {
- return positionMs
- }
- val position =
- MPVLib.getPropertyDouble("time-pos/full")?.seconds?.inWholeMilliseconds
- ?: positionMs
- return position
+ val state = playbackState.load()
+ return state.positionMs
}
override fun getBufferedPosition(): Long {
@@ -424,7 +422,7 @@ class MpvPlayer(
override fun getTotalBufferedDuration(): Long {
if (DEBUG) Timber.v("getTotalBufferedDuration")
if (isReleased) return 0
- return MPVLib.getPropertyDouble("demuxer-cache-duration")?.seconds?.inWholeMilliseconds ?: 0
+ return playbackState.load().bufferMs
}
override fun isPlayingAd(): Boolean {
@@ -446,6 +444,14 @@ class MpvPlayer(
override fun getVolume(): Float = 1f
+ override fun mute() {
+ volume = 0f
+ }
+
+ override fun unmute() {
+ volume = 1f
+ }
+
override fun clearVideoSurface(): Unit = throw UnsupportedOperationException()
override fun clearVideoSurface(surface: Surface?): Unit = throw UnsupportedOperationException()
@@ -466,9 +472,8 @@ class MpvPlayer(
MPVLib.attachSurface(surface)
MPVLib.setOptionString("force-window", "yes")
Timber.d("Attached surface")
- mediaItem?.let(::loadFile)
- if (mediaItem == null) {
- Timber.w("mediaItem is null in setVideoSurfaceView")
+ playbackState.load().media?.let {
+ sendCommand(MpvCommand.LOAD_FILE, it)
}
} else {
clearVideoSurfaceView(null)
@@ -476,11 +481,14 @@ class MpvPlayer(
}
override fun clearVideoSurfaceView(surfaceView: SurfaceView?) {
- Timber.d("clearVideoSurfaceView")
- MPVLib.detachSurface()
- MPVLib.setPropertyString("vo", "null")
- MPVLib.setPropertyString("force-window", "no")
- mediaItem = null
+ if (surface == surfaceView?.holder?.surface) {
+ Timber.d("clearVideoSurfaceView")
+ MPVLib.detachSurface()
+ MPVLib.setPropertyString("vo", "null")
+ MPVLib.setPropertyString("force-window", "no")
+ } else {
+ Timber.w("clearVideoSurfaceView called with different surface")
+ }
}
override fun setVideoTextureView(textureView: TextureView?): Unit = throw UnsupportedOperationException()
@@ -490,13 +498,7 @@ class MpvPlayer(
override fun getVideoSize(): VideoSize {
if (DEBUG) Timber.v("getVideoSize")
if (isReleased) return VideoSize.UNKNOWN
- val width = MPVLib.getPropertyInt("width")
- val height = MPVLib.getPropertyInt("height")
- return if (width != null && height != null) {
- VideoSize(width, height)
- } else {
- VideoSize.UNKNOWN
- }
+ return playbackState.load().videoSize
}
override fun getSurfaceSize(): Size = throw UnsupportedOperationException()
@@ -557,7 +559,11 @@ class MpvPlayer(
if (mediaItemIndex == C.INDEX_UNSET) {
return
}
- MPVLib.setPropertyDouble("time-pos", positionMs / 1000.0)
+ sendCommand(MpvCommand.SEEK, positionMs)
+ }
+
+ override fun onTrackSelectionsInvalidated() {
+ // no-op
}
override fun eventProperty(property: String) {
@@ -570,7 +576,11 @@ class MpvPlayer(
) {
if (DEBUG) Timber.v("eventPropertyLong: $property=$value")
when (property) {
- MPVProperty.POSITION -> positionMs = value.seconds.inWholeMilliseconds
+ MPVProperty.POSITION -> {
+ playbackState.update {
+ it.copy(positionMs = value.seconds.inWholeMilliseconds)
+ }
+ }
}
}
@@ -581,7 +591,9 @@ class MpvPlayer(
if (DEBUG) Timber.v("eventPropertyBoolean: $property=$value")
when (property) {
MPVProperty.PAUSED -> {
- isPaused = value
+ playbackState.update {
+ it.copy(isPaused = value)
+ }
notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(!value) }
}
}
@@ -600,52 +612,58 @@ class MpvPlayer(
) {
Timber.v("eventPropertyDouble: $property=$value")
when (property) {
- MPVProperty.DURATION -> durationMs = value.seconds.inWholeMilliseconds
+ MPVProperty.DURATION -> {
+ playbackState.update {
+ it.copy(durationMs = value.seconds.inWholeMilliseconds)
+ }
+ }
}
}
override fun event(eventId: Int) {
+ Timber.v("event: thread=${Thread.currentThread().name}, eventId=$eventId")
when (eventId) {
-// MPV_EVENT_START_FILE -> {
-// }
+ MPV_EVENT_START_FILE -> {
+ internalHandler.post(updatePlaybackState)
+ }
+
MPV_EVENT_FILE_LOADED -> {
- isLoadingFile = false
+ playbackState.update {
+ it.copy(isLoadingFile = false)
+ }
notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(false) }
Timber.d("event: MPV_EVENT_FILE_LOADED")
- mediaItem?.let {
- it.localConfiguration?.subtitleConfigurations?.forEach {
+ internalHandler.post(updatePlaybackState)
+ playbackState.load().media?.mediaItem?.let { media ->
+ media.localConfiguration?.subtitleConfigurations?.forEach {
val url = it.uri.toString()
val title = it.label ?: "External Subtitles"
Timber.v("Adding external subtitle track '$title'")
- MPVLib.command(arrayOf("sub-add", url, "auto", title))
+ if (it.language.isNotNullOrBlank()) {
+ MPVLib.command(arrayOf("sub-add", url, "auto", title, it.language!!))
+ } else {
+ MPVLib.command(arrayOf("sub-add", url, "auto", title))
+ }
}
}
notifyListeners(EVENT_RENDERED_FIRST_FRAME) { onRenderedFirstFrame() }
notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(true) }
- getTracks().let {
- notifyListeners(EVENT_TRACKS_CHANGED) { onTracksChanged(it) }
- }
+ updateTracksAndNotify()
}
MPV_EVENT_PLAYBACK_RESTART -> {
Timber.d("event: MPV_EVENT_PLAYBACK_RESTART")
- getTracks().let {
- notifyListeners(EVENT_TRACKS_CHANGED) { onTracksChanged(it) }
- }
+ updateTracksAndNotify()
}
MPV_EVENT_AUDIO_RECONFIG -> {
Timber.d("event: MPV_EVENT_AUDIO_RECONFIG")
- getTracks().let {
- notifyListeners(EVENT_TRACKS_CHANGED) { onTracksChanged(it) }
- }
+ updateTracksAndNotify()
}
MPV_EVENT_VIDEO_RECONFIG -> {
Timber.d("event: MPV_EVENT_VIDEO_RECONFIG")
- getTracks().let {
- notifyListeners(EVENT_TRACKS_CHANGED) { onTracksChanged(it) }
- }
+ updateTracksAndNotify()
}
MPV_EVENT_END_FILE -> {
@@ -667,6 +685,9 @@ class MpvPlayer(
notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(false) }
when (reason) {
MPV_END_FILE_REASON_EOF -> {
+ playbackState.update {
+ it.copy(state = Player.STATE_ENDED)
+ }
notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) {
onPlaybackStateChanged(STATE_ENDED)
}
@@ -695,25 +716,44 @@ class MpvPlayer(
}
}
- private fun loadFile(mediaItem: MediaItem) {
- isLoadingFile = true
+ private fun updateTracksAndNotify() {
+ val tracks = createTracks()
+ playbackState.update {
+ it.copy(tracks = tracks)
+ }
+ notifyListeners(EVENT_TRACKS_CHANGED) { onTracksChanged(tracks) }
+ }
+
+ private fun loadFile(media: MediaAndPosition) {
+ Timber.v("loadFile: media=$media")
+ playbackState.update {
+ it.copy(
+ isLoadingFile = true,
+ media = media,
+ )
+ }
notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(true) }
- val url = mediaItem.localConfiguration?.uri.toString()
- if (startPositionMs > 0) {
+ val url =
+ media.mediaItem.localConfiguration
+ ?.uri
+ .toString()
+ if (media.startPositionMs > 0) {
MPVLib.command(
arrayOf(
"loadfile",
url,
"replace",
"-1",
- "start=${startPositionMs / 1000.0}",
+ "start=${media.startPositionMs / 1000.0}",
),
)
} else {
MPVLib.command(arrayOf("loadfile", url, "replace", "-1"))
}
- MPVLib.setPropertyString("vo", "gpu")
+ if (enableHardwareDecoding) {
+ MPVLib.setOptionString("vo", if (useGpuNext) "gpu-next" else "gpu")
+ }
Timber.d("Called loadfile")
}
@@ -727,7 +767,7 @@ class MpvPlayer(
eventId: Int,
block: Player.Listener.() -> Unit,
) {
- handler.post {
+ mainHandler.post {
listeners.queueEvent(eventId) {
block.invoke(it)
}
@@ -735,84 +775,249 @@ class MpvPlayer(
}
}
- private fun getTracks(): Tracks {
- val trackCount = MPVLib.getPropertyInt("track-list/count") ?: return Tracks.EMPTY
- val groups =
- (0..
- val type = MPVLib.getPropertyString("track-list/$idx/type")
- val id = MPVLib.getPropertyInt("track-list/$idx/id")
- val lang = MPVLib.getPropertyString("track-list/$idx/lang")
- val codec = MPVLib.getPropertyString("track-list/$idx/codec")
- val codecDescription = MPVLib.getPropertyString("track-list/$idx/codec-desc")
- val isDefault = MPVLib.getPropertyBoolean("track-list/$idx/default") ?: false
- val isForced = MPVLib.getPropertyBoolean("track-list/$idx/forced") ?: false
- val isExternal = MPVLib.getPropertyBoolean("track-list/$idx/external") ?: false
- val isSelected = MPVLib.getPropertyBoolean("track-list/$idx/selected") ?: false
- val channelCount = MPVLib.getPropertyInt("track-list/$idx/demux-channel-count")
- val title = MPVLib.getPropertyString("track-list/$idx/title")
-
- if (type != null && id != null) {
- // TODO do we need the real mimetypes?
- val mimeType =
- when (type) {
- "video" -> MimeTypes.BASE_TYPE_VIDEO + "/todo"
- "audio" -> MimeTypes.BASE_TYPE_AUDIO + "/todo"
- "sub" -> MimeTypes.BASE_TYPE_TEXT + "/todo"
- else -> "unknown/todo"
- }
- var flags = 0
- if (isDefault) flags = flags or C.SELECTION_FLAG_DEFAULT
- if (isForced) flags = flags or C.SELECTION_FLAG_FORCED
- val builder =
- Format
- .Builder()
- .setId("$idx:$id")
- .setCodecs(codec)
- .setSampleMimeType(mimeType)
- .setLanguage(lang)
- .setLabel(listOfNotNull(title, codecDescription).joinToString(","))
- .setSelectionFlags(flags)
- if (type == "video" && isSelected) {
- builder.setWidth(MPVLib.getPropertyInt("width") ?: -1)
- builder.setHeight(MPVLib.getPropertyInt("height") ?: -1)
- }
- channelCount?.let(builder::setChannelCount)
- val format = builder.build()
-
- val trackGroup = TrackGroup(format)
- val group =
- Tracks.Group(
- trackGroup,
- false,
- intArrayOf(C.FORMAT_HANDLED),
- booleanArrayOf(isSelected),
- )
- group
- } else {
- null
- }
- }
- return Tracks(groups)
- }
-
- override fun onTrackSelectionsInvalidated() {
- // no-op
- }
-
- var subtitleDelay: Double
+ var subtitleDelaySeconds: Double
get() {
if (isReleased) return 0.0
- return MPVLib.getPropertyDouble("sub-delay") ?: 0.0
+ return playbackState.load().subtitleDelay
}
set(value) {
if (isReleased) return
- MPVLib.setPropertyDouble("sub-delay", value)
+ sendCommand(MpvCommand.SET_SUBTITLE_DELAY, value)
}
+
+ var subtitleDelay: Duration
+ get() {
+ if (isReleased) return Duration.ZERO
+ return subtitleDelaySeconds.seconds
+ }
+ set(value) {
+ if (isReleased) return
+ subtitleDelaySeconds = value.inWholeMilliseconds / 1000.0
+ }
+
+ private val updatePlaybackState: Runnable =
+ Runnable {
+ if (playbackState.load().media == null) {
+ return@Runnable
+ }
+ val positionMs =
+ MPVLib.getPropertyDouble("time-pos/full")?.seconds?.inWholeMilliseconds
+ ?: C.TIME_UNSET
+ val bufferMs =
+ MPVLib.getPropertyDouble("demuxer-cache-duration")?.seconds?.inWholeMilliseconds
+ ?: C.TIME_UNSET
+ val durationMs =
+ MPVLib.getPropertyDouble("duration/full")?.seconds?.inWholeMilliseconds
+ ?: C.TIME_UNSET
+ val speed = MPVLib.getPropertyDouble("speed")?.toFloat() ?: 1f
+ val paused = MPVLib.getPropertyBoolean("pause") ?: false
+ val width = MPVLib.getPropertyInt("width")
+ val height = MPVLib.getPropertyInt("height")
+ val videoSize =
+ if (width != null && height != null) {
+ VideoSize(width, height)
+ } else {
+ VideoSize.UNKNOWN
+ }
+
+ playbackState.update {
+ it.copy(
+ timestamp = System.currentTimeMillis(),
+ positionMs = positionMs,
+ bufferMs = bufferMs,
+ durationMs = durationMs,
+ speed = speed,
+ isPaused = paused,
+ videoSize = videoSize,
+ )
+ }
+ }
+
+ private fun sendCommand(
+ cmd: MpvCommand,
+ obj: Any?,
+ ) {
+ internalHandler.obtainMessage(cmd.ordinal, obj).sendToTarget()
+ }
+
+ override fun handleMessage(msg: Message): Boolean {
+ val cmd = MpvCommand.entries[msg.what]
+ Timber.v("handleMessage: cmd=$cmd")
+ when (cmd) {
+ MpvCommand.PLAY_PAUSE -> {
+ val playWhenReady = msg.obj as Boolean
+ MPVLib.setPropertyBoolean("pause", !playWhenReady)
+ playbackState.update {
+ it.copy(isPaused = !playWhenReady)
+ }
+ notifyListeners(EVENT_PLAY_WHEN_READY_CHANGED) {
+ onPlayWhenReadyChanged(
+ playWhenReady,
+ PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST,
+ )
+ }
+ }
+
+ MpvCommand.SET_TRACK_SELECTION -> {
+ val (propertyName, trackId) = msg.obj as TrackSelection
+ MPVLib.setPropertyString(propertyName, trackId)
+ updateTracksAndNotify()
+ }
+
+ MpvCommand.SEEK -> {
+ val positionMs = msg.obj as Long
+ MPVLib.setPropertyDouble("time-pos", positionMs / 1000.0)
+ playbackState.update {
+ it.copy(positionMs = positionMs)
+ }
+ }
+
+ MpvCommand.SET_SPEED -> {
+ val value = msg.obj as Float
+ MPVLib.setPropertyDouble("speed", value.toDouble())
+ playbackState.update {
+ it.copy(speed = value)
+ }
+ }
+
+ MpvCommand.SET_SUBTITLE_DELAY -> {
+ val value = msg.obj as Double
+ MPVLib.setPropertyDouble("sub-delay", value)
+ playbackState.update {
+ it.copy(subtitleDelay = value)
+ }
+ }
+
+ MpvCommand.LOAD_FILE -> {
+ loadFile(msg.obj as MediaAndPosition)
+ }
+ }
+ return true
+ }
}
fun MPVLib.setPropertyColor(
property: String,
color: Color,
-) = MPVLib.setPropertyString(property, color.mpvFormat)
+) = setPropertyString(property, color.mpvFormat)
private val Color.mpvFormat: String get() = "$red/$green/$blue/$alpha"
+
+@OptIn(UnstableApi::class)
+private fun createTracks(): Tracks {
+ val trackCount = MPVLib.getPropertyInt("track-list/count") ?: return Tracks.EMPTY
+ val groups =
+ (0..
+ val type = MPVLib.getPropertyString("track-list/$idx/type")
+ val id = MPVLib.getPropertyInt("track-list/$idx/id")
+ val lang = MPVLib.getPropertyString("track-list/$idx/lang")
+ val codec = MPVLib.getPropertyString("track-list/$idx/codec")
+ val codecDescription = MPVLib.getPropertyString("track-list/$idx/codec-desc")
+ val isDefault = MPVLib.getPropertyBoolean("track-list/$idx/default") ?: false
+ val isForced = MPVLib.getPropertyBoolean("track-list/$idx/forced") ?: false
+ val isExternal = MPVLib.getPropertyBoolean("track-list/$idx/external") ?: false
+ val isSelected = MPVLib.getPropertyBoolean("track-list/$idx/selected") ?: false
+ val channelCount = MPVLib.getPropertyInt("track-list/$idx/demux-channel-count")
+ val title = MPVLib.getPropertyString("track-list/$idx/title")
+
+ if (type != null && id != null) {
+ // TODO do we need the real mimetypes?
+ val mimeType =
+ when (type) {
+ "video" -> MimeTypes.BASE_TYPE_VIDEO + "/todo"
+ "audio" -> MimeTypes.BASE_TYPE_AUDIO + "/todo"
+ "sub" -> MimeTypes.BASE_TYPE_TEXT + "/todo"
+ else -> "unknown/todo"
+ }
+ var flags = 0
+ if (isDefault) flags = flags or C.SELECTION_FLAG_DEFAULT
+ if (isForced) flags = flags or C.SELECTION_FLAG_FORCED
+ val builder =
+ Format
+ .Builder()
+ .apply {
+ if (isExternal) {
+ setId("$idx:e:$id")
+ } else {
+ setId("$idx:$id")
+ }
+ }.setCodecs(codec)
+ .setSampleMimeType(mimeType)
+ .setLanguage(lang)
+ .setLabel(listOfNotNull(title, codecDescription).joinToString(","))
+ .setSelectionFlags(flags)
+ if (type == "video" && isSelected) {
+ builder.setWidth(MPVLib.getPropertyInt("width") ?: -1)
+ builder.setHeight(MPVLib.getPropertyInt("height") ?: -1)
+ }
+ channelCount?.let(builder::setChannelCount)
+ val format = builder.build()
+// Timber.v("$idx=$format")
+
+ val trackGroup = TrackGroup(format)
+ val group =
+ Tracks.Group(
+ trackGroup,
+ false,
+ intArrayOf(C.FORMAT_HANDLED),
+ booleanArrayOf(isSelected),
+ )
+ group
+ } else {
+ null
+ }
+ }
+ return Tracks(groups)
+}
+
+private data class PlaybackState(
+ val timestamp: Long,
+ val isLoadingFile: Boolean,
+ val media: MediaAndPosition?,
+ val positionMs: Long,
+ val bufferMs: Long,
+ val durationMs: Long,
+ val isPaused: Boolean,
+ val speed: Float,
+ val subtitleDelay: Double,
+ val videoSize: VideoSize,
+ @param:Player.State val state: Int,
+ val tracks: Tracks,
+) {
+ companion object {
+ val EMPTY =
+ PlaybackState(
+ timestamp = C.TIME_UNSET,
+ isLoadingFile = false,
+ media = null,
+ positionMs = C.TIME_UNSET,
+ durationMs = C.TIME_UNSET,
+ tracks = Tracks.EMPTY,
+ bufferMs = C.TIME_UNSET,
+ isPaused = false,
+ speed = 1f,
+ videoSize = VideoSize.UNKNOWN,
+ state = Player.STATE_IDLE,
+ subtitleDelay = 0.0,
+ )
+ }
+}
+
+private data class TrackSelection(
+ val property: String,
+ val trackId: String,
+)
+
+private data class MediaAndPosition(
+ val mediaItem: MediaItem,
+ val startPositionMs: Long,
+)
+
+enum class MpvCommand {
+ PLAY_PAUSE,
+ SEEK,
+ SET_TRACK_SELECTION,
+ SET_SPEED,
+ SET_SUBTITLE_DELAY,
+ LOAD_FILE,
+}
diff --git a/app/src/main/jni/log.h b/app/src/main/jni/log.h
index 58fbda05..bef941b2 100644
--- a/app/src/main/jni/log.h
+++ b/app/src/main/jni/log.h
@@ -2,7 +2,7 @@
#include
-#define DEBUG 1
+#define DEBUG 0
#define LOG_TAG "mpv"
#define ALOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto
index fe601b0b..f10b49c5 100644
--- a/app/src/main/proto/WholphinDataStore.proto
+++ b/app/src/main/proto/WholphinDataStore.proto
@@ -125,6 +125,19 @@ message SubtitlePreferences{
int32 edge_thickness = 12;
}
+message LiveTvPreferences {
+ bool show_header = 1;
+ bool favorite_channels_at_beginning = 2;
+ bool sort_by_recently_watched = 3;
+ bool color_code_programs = 4;
+}
+
+enum BackdropStyle{
+ BACKDROP_DYNAMIC_COLOR = 0;
+ BACKDROP_IMAGE_ONLY = 1;
+ BACKDROP_NONE = 2;
+}
+
message InterfacePreferences {
ThemeSongVolume play_theme_songs = 1;
bool remember_selected_tab = 2;
@@ -133,6 +146,8 @@ message InterfacePreferences {
bool nav_drawer_switch_on_focus = 5;
bool show_clock = 6;
SubtitlePreferences subtitles_preferences = 7;
+ LiveTvPreferences live_tv_preferences = 8;
+ BackdropStyle backdrop_style = 9;
}
message AdvancedPreferences {
diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml
new file mode 100644
index 00000000..045e125f
--- /dev/null
+++ b/app/src/main/res/values-ca/strings.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml
new file mode 100644
index 00000000..4d7b297d
--- /dev/null
+++ b/app/src/main/res/values-el/strings.xml
@@ -0,0 +1,212 @@
+
+
+ Ταινίες
+ Προφίλ
+ Αρχική
+ Γλώσσα
+ Θέμα εφαρμογής
+ Υπότιτλοι
+ Υπότιτλος
+ Κανάλια
+ Ναι
+ Όχι
+ Προηγμένες ρυθμίσεις
+ Γεννήθηκε
+ Τόπος γέννησης
+ Ήχος
+ Το βίντεο
+ Τα βίντεο
+ Παρασκήνιο
+
+ - Θεματικά τραγούδια
+
+
+
+ - Θεματικά βίντεο
+
+
+ Επόμενο
+ Ενεργοποιήστε την επανάληψη παρακολούθησης στα \'Επόμενα\'
+ Γραμματοσειρά
+ Μέγεθος Κειμένου
+ Χρώμα Κειμένου
+ Φωτεινότητα κειμένου
+ Βιβλιοθήκης
+ Σειρές
+ Είδη
+ Συλλογή
+ Συλλογές
+ Αγαπημένο
+ Αγαπημένα
+ Προτάσεις
+ Σκηνοθέτης
+ Στούντιο
+ SDR
+
+ Περισσότερα Σαν Αυτό
+ Επεισόδια
+ #ΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ
+
+ - %s λήψη
+ - %s λήψεις
+
+ λήψη…
+ Διαδρομή
+ Όνομα
+ Δοχείο
+ Μέγεθος
+ Κωδικοποιητής
+ AVC
+ Ρυθμός bit
+ Βάθος bit
+ bit
+ Εξαναγκασμός
+ Σχέδιο
+ Προεπιλογή
+ Εξωτερικό
+ Προσθήκη χρήστη
+ Ακύρωση
+ Κεφάλαια
+ Διάλεξε %1$s
+ Προσθήκη διακομιστή
+ Ακύρωση καταχώρησης
+ Ακύρωση σειρά καταχώρησης
+ Καθαρισμός μνήμης εικόνας
+ Διαφήμιση
+ Αξιολόγηση κοινού
+ Ένα σφάλμα εμφανίστηκε! Πατήστε το κομπί για να στείλετε αρχείο καταγραφής στο διακομιστή.
+ Επικύρωση
+ Συνεχίστε να βλέπετε
+ Κριτική
+ %.1f δευτερόλεπτα
+ Διαγραφή
+ Πέθανε
+ Σκηνοθετήθηκε από %1$s
+ Απενεργοποιημένος
+ Ενεργοποιημένος
+ Καταχώρηση διακομιστή IP or URL
+ Σφάλμα φόρτωσης συλλογής %1$s
+ Πηγαίνετε στις σειρές
+ Πηγαίνετε στο
+ Απόκρυψη χειρισμού αναπαραγωγής
+ Απόκρυψη πληροφοριών εντοπισμού σφαλμάτων
+ Απόκρυψη
+ Άμεσος
+ Εισαγωγή
+ Πληροφορίες άδειας
+ Ζωντανή Τηλεόραση
+ Φόρτωση…
+ Επισήμανση ολόκληρης σειράς ως αναπαραγμένης?
+ Επισήμανση ολόκληρης σειράς ως μη αναπαραγμένης?
+ Επισήμανση ως μη ολοκληρωμένο
+ Επισήμανση ως ολοκληρωμένο
+ Μέγιστος ρυθμός μετάδοσης δεδομένων
+ Περισσότερα
+ Χωρίς δεδομένα
+ Χωρίς αποτελέσματα
+ Χωρίς προγραμματισμένες καταχωρήσεις
+ Χωρίς διαθέσιμη ενημέρωση
+ Έναρξη από εδώ
+ Έναρξη
+ Άτομα
+ Ταχύτητα αναπαραγωγής
+ Αναπαραγωγή
+ Λίστα αναπαραγωγής
+ Λίστες αναπαραγωγής
+ Παρουσίαση
+ Ρυθμίσεις προφίλ χρήστη
+ Ουρά
+ Ανακεφαλαίωση
+ Πρόσφατα πρόσθετα σε %1$s
+ Πρόσφατα πρόσθετα
+ Πρόσφατα καταγεγραμμένο
+ Πρόσφατα κυκλοφόρησε
+ Προτεινόμενο
+ Πρόγραμμα καταγραφής
+ Σειρά καταγραφής
+ Κατάργηση από τα αγαπημένα
+ Επανεκκίνηση
+ Περίληψη
+ Αποθήκευση
+ Αναζήτηση
+ Αναζήτηση…
+ Επιλογή διακομιστή
+ Επιλογή χρήστη
+ Εμφάνιση πληροφοριών εντοπισμού σφαλμάτων
+ Εμφάνιση επόμενου
+ Εμφάνιση
+ Τυχαία αναπαραγωγή
+ Αναπήδηση
+ Προσθήκη ημερομηνίας
+ Προσθήκη ημερομηνίας επεισοδίου
+ Ημερομηνία αναπαραγωγής
+ Ημερομηνία κυκλοφορίας
+ Όνομα
+ Τυχαία
+ Υποβολή
+ Ενεργές καταγραφές
+ Κανένας
+ Εμφάνιση πληροφοριών αναπαραγωγής σφαλμάτων
+ Λήψη αρχείων διαρκεί πολύ χρονο, ίσως χρειαστεί να επανεκκινήσετε την αναπαραγωγή
+ Αλλαγή διακομιστή
+ Αντικατάσταση
+ Κορυφαίες βαθμολογίες σε ταινίες που δεν έχω δει
+ Οδηγός
+ Σεζόν
+ Περισσότερες Σεζόν
+ Άγνωστο
+ Αναβαθμίσεις
+ Εκδοχή
+ Κλίμακα βίντεο
+ Ζωντανή μετάδοση
+ Πληροφορίες μέσων
+ Εμφάνιση ώρας
+ Δημιουργία νέας λίστας αναπαραγωγής
+ Προσθήκη στη λίστα αναπαραγωγής
+ Παύση με ένα κουμπί
+ Πλάγια γραμματοσειρά
+ Επιτυχής
+ Γονεική αξιολόγηση
+ Χρόνος εκτέλεσης
+ Επιπρόσθετα
+
+ - Άλλο
+
+
+
+ - Παρασκήνια
+
+
+
+ - Αποσπάσματα
+
+
+
+ - Διαγραμμένες σκηνές
+
+
+
+ - Συνεντεύξεις
+
+
+
+ - Σκηνές
+
+
+
+ - Δείγματα
+
+
+
+ - Χαρακτηριστικά
+
+
+ Αυτόματος έλεγχος για ενημερώσεις
+ Καθυστέρηση πριν την έναρξη του επόμενου
+ Αυτόματη έναρξη επόμενου
+ Έλεχγος για ενημερώσεις
+ Ισχύει μόνο για τηλεοπτικές σειρές
+ Προεπιλεγμένη αναπαραγωγή περιεχομένου
+ Εγκατάσταση ενημέρωσης
+ Εγκατεστημένη εκδοχή
+
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 7bdbf50d..f1ca329f 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -165,8 +165,8 @@
Poner la fuente en cursiva
- Detrás de las cámaras
-
-
+
+
- Canciones principales
@@ -175,63 +175,63 @@
- Vídeos temáticos
-
-
+
+
- Clips
-
-
+
+
- Escenas eliminadas
-
-
+
+
- Entrevistas
-
-
+
+
- Escenas
-
-
+
+
- Fragmentos
-
-
+
+
- Mini-documentales
-
-
+
+
- Cortos
-
-
+
+
- %s descarga
- %s descargas
-
+
- %d hora
- %d horas
-
+
- %d elemento
- %d elementos
-
+
- %d segundo
- %d segundos
-
+
El dispositivo es compatible con AC3/Dolby Digital
Comprobar actualizaciones automáticamente
diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml
index 92428ff4..59aa1f7b 100644
--- a/app/src/main/res/values-et/strings.xml
+++ b/app/src/main/res/values-et/strings.xml
@@ -223,7 +223,7 @@
Treiler
- Treilerid
-
+
DVR-i kava
Telekava
@@ -287,4 +287,75 @@
Käitumine sisukokkuvõtte vahelejätmisel
Taasesituse jätkamisel jäta vahele tagasisuunas
Vaataja uinumise taimer
+ Veeris
+ Detailne logimine
+ Pildipuhvri suurus (MB)
+ Sisesta PIN-kood
+ Logi automaatselt sisse
+ Logi sisse serveri abil
+ Kinnitamiseks vajuta keskele
+ Küsi profiili jaoks PIN-koodi
+ Korda PIN-koodi
+ Pole õige
+ PIN-kood peab olema 4 või enam märki pikk
+ Sellega eemaldatakse PIN-kood
+ MPV: kasuta gpu-next liidestust
+ Muuda mpv.conf faili
+ Kas loobud muudatustest?
+ Vaate seaded
+ Veerud
+ Ruumivahed
+ Küljesuhe
+ Näita üksikasju
+ Pildi tüüp
+
+ Külalisnäitlejad
+ Ääre suurus
+ Kaadrisageduse vahetamine
+ Automaatne
+ Pruugi kasutajanime ja salasõna
+ Logi sisse
+ Näita pealkirju
+ Meediumi teave
+ Üldist
+ Konteiner
+ Pealkiri
+ Koodek
+ Profiil
+ Tase
+ Resolutsioon
+ Jah
+ Ei
+ HDR
+ HDR10
+ bitti
+ HLG
+ Hz
+ HDR10+
+ SDR
+ AVC
+ Anamorfne
+ Ülereaskaneering
+ Kaadrisagedus
+ Bitisügavus
+ Video bitisügavus
+ Video bitisügavuse tüüp
+ Värviruum
+ Värvide ülekanne
+ Keel
+ Paigutus
+ Kanaleid
+ Diskreetimissagedus
+ Põhivärvused
+ Pikslivorming
+ Aluskaadreid
+ NAL
+ Kordus
+ Näita esmalt lemmikkanaleid
+ Järjesta kanalid viimase vaatamise alusel
+ Märgi programmid värvidega
+ Subtiitrite viivitus
+ Sisesta serveri aadress
+ Ühtegi serverit ei leidu
+ Tausta dekoratsioon
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 208650bb..ba3fb676 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -197,7 +197,7 @@
Échelle de contenu par défaut
Installer la mise à jour
Version installée
- Nombre max d’éléments sur les lignes de la page d\'accueil
+ Nombre d’éléments sur les lignes de la page d\'accueil
Choisissez les éléments à afficher par défaut, les autres seront masqués
Personnaliser les éléments du menu de navigation
Cliquez pour changer de page
@@ -303,4 +303,71 @@
Dolby Vision
Dolby Atmos
Remplacer la lecture
+ Marge
+ MPV: Utiliser gpu-next
+ Éditer mpv.conf
+ Annuler les modifications ?
+ Journalisation détaillée
+ Saisir le code PIN
+ Connexion automatique
+ Connexion via serveur
+ Appuyez au centre pour confirmer
+ Code PIN requis pour le profil
+ Confirmer le code PIN
+ Erroné
+ Le code PIN doit comporter au moins 4 éléments
+ Supprimera le code PIN
+ Taille du cache disque de l\'image (Mo)
+ Options d\'affichage
+ Colonnes
+ Espacement
+ Format d\'image
+ Afficher les détails
+
+ Invités spéciaux
+ Taille des bordures
+ Changement de fréquence de rafraîchissement
+ Automatique
+ Utiliser un nom d\'utilisateur/mot de passe
+ Connexion
+ Afficher les titres
+ Type d\'image
+ Informations sur le média
+ Général
+ Conteneur
+ Titre
+ Codec
+ Profil
+ Niveau
+ Résolution
+ Anamorphique
+ Entrelacé
+ Fréquence d\'images
+ Profondeur de bits
+ Plage vidéo
+ Type de plage vidéo
+ Espace colorimétrique
+ Transfert des couleurs
+ Format de pixel
+ Langue
+ Mise en page
+ Chaînes
+ Taux d’échantillonnage
+ AVC
+ Oui
+ Non
+ SDR
+ HDR
+ HDR10
+ HDR10+
+ bit
+ Hz
+ Répéter
+ Afficher les chaînes favorites en premier
+ NAL
+ HLG
+ Couleurs primaires
+ Images de référence
+ Trier les chaînes par date de visionnage
+ Classer les programmes par code couleur
diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml
new file mode 100644
index 00000000..7d0320bb
--- /dev/null
+++ b/app/src/main/res/values-hu/strings.xml
@@ -0,0 +1,216 @@
+
+
+ Névjegy
+ Kedvenc
+ Szerver hozzáadása
+ Felhasználó hozzáadása
+ Audio
+ Születési hely
+ Bitráta
+ Született
+ Mégse
+ Fejezetek
+ Válassz %1$s
+ Kép gyorsítótár törlése
+ Gyűjtemény
+ Gyűjtemények
+ Közösségi értékelés
+ Hiba történt! Nyomd meg a gombot, hogy elküldd a naplófájlokat a szervernek.
+ Megerősít
+ Kritikusi értékelés
+ %.1f másodperc
+ Alapértelmezett
+ Törlés
+ Rendezte %1$s
+ Rendező
+ Letiltva
+ Felfedezett szerverek
+
+ Letöltés folyamatban…
+ Engedélyezve
+ Add meg a szerver IP-t vagy URL-t
+ Epizódok
+ Hiba a gyűjtemény betöltése során: %1$s
+ Külső
+ Kedvencek
+ Aktív felvételek
+ Felvétel leállítása
+ Sorozatfelvétel leállítása
+ Reklám
+ Megtekintés folytatása
+ Halott
+ Méret
+ Kényszerített
+ Műfajok
+ Ugrás a sorozathoz
+ Ugrás
+ Lejátszási gombok elrejtése
+ Hibajavítási infó elrejtése
+ Elrejt
+ Kezdőlap
+ Azonnal
+ Intro
+ #ABCDEFGHIJKLMNOPQRSTUVWXYZ
+ Könyvtár
+ Lincenc információk
+ Élő TV
+ Betöltés…
+ Megjelölöd az egész sorozatot megnézettként?
+ Megjelölöd az egész sorozatot megnézetlenként?
+ Jelölés megnézetlenként
+ Jelölés megnézettként
+ Max bitráta
+ Hasonlóak
+ Továbbiak
+ Filmek
+ Név
+ Következő
+ Nincs adat
+ Nincs találat
+ Nincs időzített felvétel
+ Nincs elérhető frissítés
+ Nincs
+ Outro
+ Elérési út
+ Emberek
+ Lejátszások száma
+ Lejátszás innen
+ Lejátszás
+ Lejátszás hibakeresési adatainak mutatása
+ Lejátszási sebesség
+ Visszajátszás
+ Lejátszási lista
+ Lejátszási listák
+ Előnézet
+ Felhasználó beállításai
+ Lejátszási sor
+ Nemrég hozzáadva: %1$s
+ Nemrég hozzáadott
+ Nemrég rögzített
+ Nemrég kiadott
+ Ajánlott
+ Program felvétele
+ Sorozat felvétele
+ Törlés a kedvencekből
+ Újraindítás
+ Folytatás
+ Mentés
+
+ Keresés
+ Keresés…
+ Szerver kiválasztása
+ Felhasználó kiválasztása
+ Hibakeresési info megjelenítése
+ Következő mutatása
+ Megmutat
+ Véletlenszerű
+ Átugrás
+ Hozzáadva
+ Epizód hozzáadásának dátuma
+ Lejátszás dátuma
+ Megjelenés dátuma
+ Név
+ Random
+ Stúdiók
+ Beküld
+ A letöltés sok időt vesz igénybe, előfordulhat, hogy újra kell indítanod a lejátszást
+ Felirat
+ Feliratok
+ Ajánlások
+ Váltás szerverek között
+ Váltás
+ Legjobbra értékelt nem megtekintett
+ Előzetes
+
+ - Előzetesek
+
+
+ DVR Időzítés
+ Műsorújság
+ Évad
+ Évadok
+ Sorozatok
+ Felület
+ Ismeretlen
+ Frissítések
+ Verzió
+ Képarány
+ Videó
+ Videók
+ Nézd élőben
+ %1$d éves
+ Lejátszás átalakítással
+ Aktuális idő mutatása
+ Sorrend epízód vetítése alapján
+ Új lejátszási lista létrehozása
+ Hozzáadás lejátszási listához
+ Szüneteltetés egy klikkel
+ Nyomd a D-Pad közepét megállításhoz/lejátszáshoz
+ Dőlt betűk
+ Betűtípus
+ Háttérszín
+ Sikeres
+ Szülői besorolás
+ Műsoridő
+ Extrák
+
+ - Egyéb
+
+
+
+ - Színfalak mögött
+
+
+
+ - Téma zenék
+
+
+
+ - Téma videók
+
+
+
+ - Klippek
+
+
+
+ - Törölt jelenetek
+
+
+
+ - Interjúk
+
+
+
+ - Jelenetek
+
+
+
+ - Minták
+
+
+
+ - Rövidfilmek
+
+
+
+ - Rövidek
+
+
+
+ - %s letöltés
+ - %s letöltések
+
+
+ - %d óra
+ - %d órák
+
+
+ - %d elem
+ - %d elemek
+
+
+ - %d másodperc
+ - %d másodpercek
+
+
diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml
index ab1a6c76..fc1f6866 100644
--- a/app/src/main/res/values-in/strings.xml
+++ b/app/src/main/res/values-in/strings.xml
@@ -269,4 +269,67 @@
Tahun
Dekade
Hapus
+ Dolby Vision
+ Dolby Atmos
+ Informasi Media
+ MPV: Gunakan gpu-next
+ Edit mpv.conf
+ Batalkan perubahan?
+ Masukkan PIN
+ Masuk secara otomatis
+ Masuk via server
+ Tekan tengah untuk konfirmasi
+ Konfirmasi PIN
+ Salah
+ PIN harus 4 digit atau lebih
+ Ukuran cache disk gambar (MB)
+ Kolom
+ Rasio Aspek
+ Tampilkan detail
+ Jenis gambar
+
+ Pemeran Tamu
+ Anamorfik
+ Interlace
+ Framerate
+ Kedalaman Bit
+ Rentang Video
+ Jenis rentang video
+ Color space
+ Format piksel
+ NAL
+ Bahasa
+ AVC
+ Ya
+ Tidak
+ SDR
+ HDR
+ HDR10
+ HDR10+
+ HLG
+ bit
+ Hz
+ Tampilkan judul
+ Ulangi
+ Tampilkan saluran favorit dahulu
+ Urutkan saluran berdasarkan yang baru ditonton
+ Program kode warna
+ Pencatatan rinci
+ Membutuhkan PIN untuk profil
+ PIN akan dihapus
+ Opsi tampilan
+ Ukuran garis tepi
+ Otomatis
+ Gunakan nama pengguna/kata sandi
+ Batas tepi
+ Masuk
+ Umum
+ Kontainer
+ Judul
+ Codec
+ Profil
+ Level
+ Resolusi
+ Sampel rate
+ Jarak
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index 4d88195b..faf958b2 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -303,4 +303,72 @@
Salta Riassunto
Sovrascrivi riproduzione
Salta indietro al riavvio della riproduzione
+ Margine
+ Log dettagliati
+ Dimensione cache Immagini su disco (MB)
+ MPV: Usa gpu-next
+ Modifica mpv.conf
+ Annullare le modifiche?
+ Inserisci PIN
+ Accedi automaticamente
+ Accedi tramite server
+ Premi centro per confermare
+ Richiedi PIN per il profilo
+ Conferma PIN
+ Errato
+ Il PIN deve essere di 4 cifre o più
+ Rimuoverà il PIN
+ Opzioni di visualizzazione
+ Colonne
+ Spaziatura
+ Mostra dettagli
+ Rapporto d’aspetto
+ Tipo di immagine
+
+ Guest star
+ Dimensione del bordo
+ Accedi
+ Automatico
+ Cambio frequenza di aggiornamento
+ Usa nome utente/password
+ Informazioni sui media
+ Generali
+ Titolo
+ Codifica
+ Profilo
+ Livello
+ Risoluzione
+ Anamorfico
+ Interlacciato
+ Range del video
+ Spazio colore
+ trasferimento colore
+ Colori primari
+ Formato pixel
+ Lingua
+ Canali
+ Si
+ No
+ SDR
+ HDR
+ HDR10
+ HDR10+
+ HLG
+ bit
+ Hz
+ Mostra titoli
+ AVC
+ Frequenza fotogrammi
+ Profondità bit
+ Contenitore
+ Tipo di gamma video
+ NAL
+ Frequenza di campionamento
+ Frame di riferimento
+ Disposizione
+ Ripeti
+ Mostra prima i canali preferiti
+ Ordina canali per ultimo guardato
+ Codifica colori per i programmi
+ Ritardo sottotitoli
diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml
new file mode 100644
index 00000000..045e125f
--- /dev/null
+++ b/app/src/main/res/values-iw/strings.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index 0825a25c..7068d379 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -124,7 +124,7 @@
Trailer
- Trailers
-
+
DVR-schema
Gids
@@ -156,47 +156,47 @@
Extra\'s
- Overig
-
+
- Achter de schermen
-
+
- Titelmuziek
-
+
- Titelvideo\'s
-
+
- Clips
-
+
- Verwijderde scènes
-
+
- Interviews
-
+
- Scènes
-
+
- Voorvertoningen
-
+
- Featurettes
-
+
- Shorts
-
+
- %s download
@@ -287,4 +287,71 @@
Verwijderen
Dolby Vision
Dolby Atmos
+ Marge
+ Uitgebreid logboek aanleggen
+ Omvang van afbeeldingscache (in MB)
+ Mpv: gpu-next gebruiken
+ mpv.conf bewerken
+ Wijzigingen negeren?
+ Pincode invoeren
+ Automatisch inloggen
+ Inloggen via server
+ Druk op het midden om te bevestigen
+ Profiel voorzien van pincode
+ Pincode bevestigen
+ Onjuist
+ De pincode dient 4 of meer tekens te bevatten
+ De pincode wordt gewist
+ Weergaveopties
+ Kolommen
+ Tussenruimte
+ Beeldverhouding
+ Details bekijken
+ Afbeeldingstype
+
+ Gastrollen
+ Randbreedte
+ Automatische ververssnelheid toepassen
+ Automatisch
+ Gebruikersnaam/wachtwoord gebruiken
+ Inloggen
+ Titels tonen
+ Media-informatie
+ Algemeen
+ Container
+ Titel
+ Codec
+ Profiel
+ Niveau
+ Resolutie
+ Anamorfisch
+ Met interlatie
+ Framesnelheid
+ Bitdiepte
+ Videobereik
+ Soort videobereik
+ Kleurruimte
+ Kleuroverdracht
+ Voorkeurskleuren
+ Pixelformaat
+ Referentieframes
+ NAL
+ Taal
+ Indeling
+ Kanalen
+ Samplesnelheid
+ AVC
+ Ja
+ Nee
+ Sdr
+ Hdr
+ Hdr10
+ Hdr10+
+ Hlg
+ bit
+ Hz
+ Herhalen
+ Favoriete kanalen eerst tonen
+ Kanalen sorteren op onlangs bekeken
+ Programma\'s voorzien van kleurcode
diff --git a/app/src/main/res/values-nn/strings.xml b/app/src/main/res/values-nn/strings.xml
new file mode 100644
index 00000000..fd8c6cce
--- /dev/null
+++ b/app/src/main/res/values-nn/strings.xml
@@ -0,0 +1,24 @@
+
+
+ Om
+ Aktive opptak
+ Favoritt
+ Legg til Server
+ Legg til Brukar
+ Lyd
+ Fødestad
+ Bitrate
+ Født
+ Avbryt opptak
+ Avbryt opptak av serie
+ Avbryt
+ Kapittel
+ Vel %1$s
+ Samling
+ Samlingar
+ Bekreft
+ Standard
+ Slett
+ Dødd
+ Regissert av %1$s
+
diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
new file mode 100644
index 00000000..69a8f90a
--- /dev/null
+++ b/app/src/main/res/values-pl/strings.xml
@@ -0,0 +1,301 @@
+
+
+ Dodaj serwer
+ Dodaj użytkownika
+ Dźwięk
+ Miejsce urodzenia
+ Bitrate
+ Potwierdź
+ Kontynuuj oglądanie
+ Ocena krytyków
+ Pobieranie…
+ Odcinki
+ Biblioteka
+ Informacje o licencji
+ Ładowanie…
+ Oznaczyć serial jako odtworzony?
+ Oznaczyć cały sezon jako nieodtworzony?
+ Oznacz jako nieobejrzane
+ Oznacz jako obejrzane
+ Maksymalny bitrate
+ Podobne
+ Więcej
+ Filmy
+ Następnie
+ Brak danych
+ Brak wyników
+ Brak zaplanowanych nagrań
+ Brak dostępnych aktualizacji
+ Prędkość odtwarzania
+ Odtwarzanie
+ Polecane
+ Zapisz program
+ Pomiń
+ Data dodania
+ Data Dodania Odcinka
+ Seriale
+ Sezon
+ Sezony
+ Napisy
+ Napisy
+ Propozycje
+ Zmień serwer
+ Zmień
+ Najlepiej Oceniane Nieobejrzane
+ Zwiastun
+
+ - Zwiastuny
+
+
+
+
+ Potwierdź
+ Data Wydania
+ Nazwa
+ Losowy
+ Przewodnik TV
+ Studia Filmowe
+ Losuj
+ Pokaż następne
+ Ostatnio Wydane
+ Odtwórz
+ Pokaż informacje debugowania odtwarzacza
+ Napisy końcowe
+ Ścieżka
+ Playlisty
+ Podgląd
+ Ustawienia Profilu Użytkownika
+ Kolejka
+ Zapisz
+ Szukaj
+ Wyszukiwanie…
+ Ostatnio dodane
+ Ostatnio Nagrane
+ Ostatnio dodane w %1$s
+ Wznów
+ Data Odtworzenia
+ Ulubione
+ Anuluj
+ Rozdziały
+ Wybierz %1$s
+ Wyczyść pamięć podręczną obrazów
+ Ocena społeczności
+ Wystąpił błąd. Nacisnij przycisk żeby wysłać logi na Twój serwer.
+ Reżyser
+ Gatunek
+ Idź do serialu
+ Idż do
+ Ukryj przyciski odtwarzania
+ Ukryj informacje o debugowaniu
+ Ukryj
+ Strona Główna
+ Ulubione
+ Usuń
+ Domyślna ścieżka
+ Odnalezione Serwery
+ Wprowadż IP serwera lub adres URL
+ Rozmiar
+ Wymuszona ścieżka
+ Wstęp
+ Nazwa
+ Usuń z ulubionych
+ Uruchom ponownie
+ Wybierz Serwer
+ Wybierz Użytkownika
+ Pokaż infomacje o debugowaniu
+ Użyj nazwy użytkownika/hasła
+ Login
+ Kolumny
+ Odstępy
+ Pokaż szczegóły
+ Wprowadź PIN
+ Odstęp
+ Rok
+ Dekada
+ Usuń
+ Telewizja
+ Odtwórz z transkodowaniem
+ Pokaż Zegar
+ Czcionka
+ Tło
+ Pauza jednym kliknięciem
+ Wciśnij środek by pauzować/wznowić
+
+ - Wywiady
+
+
+
+
+
+ - Sceny
+
+
+
+
+
+ - Czołówki
+
+
+
+
+ Utwórz nową playliste
+ Dodaj do playlisty
+ Wersja
+ Aktualizacje
+ Anuluj Nagrywanie
+ Zobacz opcje
+ Potwierdź PIN
+ Nieprawidłowe
+ PIN musi zawierać 4 lub więcej znaków
+ Wymagaj PIN dla profilu
+ Dolby Vision
+ Dolby Atmos
+ MPV: użyj gpu-next
+ Edytuj mpv.conf
+ Odrzucić zmiany?
+ Naciśnij środek aby potwierdzić
+ Rozmiar pamięci tymczasowej obrazów (MB)
+ Wyłącz jeśli doświadczasz problemów
+ Opcje MPV
+ Opcje odtwarzacza ExoPlayer
+ Pomiń Segment
+ Pomiń Reklamy
+ Pomiń czołówkę
+ Pomiń napisy końcowe
+ Loguj automatycznie
+ Reset
+ Czcionka pogrubiona
+ Styl obramowania
+ Kolor obramowania
+ Przezroczystość tła
+ Styl tła
+ Styl napisów
+ Kolor tła
+ Uaktualnij adres URL
+ Rozmiar czcionki
+ Kolor czcionki
+ Ustawienia
+ Wyślij raport o błędach
+ Aktualizacja dostępna
+ MPV: używaj dekodowania sprzętowego
+ Filtr
+ Czcionka kursywy
+
+ - Za kulisami
+
+
+
+
+ Ustawienia zaawansowane
+ Sprawdź dostępność aktualizacji
+ Automatycznie sprawdzaj dostępność aktualizacji
+ Zainstaluj aktualizacje
+ Zainstalowana wersja
+ Użyj dekodera FFmpeg
+ Rozmiar ramki
+ O Wholphin
+ Nagrania w toku
+ Data Urodzenia
+ Anuluj Nagrywanie Serialu
+ Kolekcja
+ Kolekcje
+ Reklama
+ %.1f sekundy
+ Nieaktywne
+
+ Wyreżyserowane przez %1$s
+ Aktywne
+ Błąd podczas ładowania kolekcji %1$s
+ Ścieżka zewnętrzna
+ #ABCDEFGHIJKLMNOPQRSTUVWXYZ
+
+ - %d godzina
+ - %d godziny
+ - %d godzin
+ - %d godzin
+
+ Typ obrazu
+
+ Domyślne skalowanie
+ Odtwarzaj bezpośrednio napisy ASS
+ Odtwarzaj bezpośrednio napisy PGS
+ Zawsze miksuj w dół do stereo
+ Motyw interfejsu
+
+ - %s pobieranie
+ - %s pobierania
+ - %s pobierań
+ - %s pobierań
+
+
+ - %d sekunda
+ - %d sekundy
+ - %d sekund
+ - %d sekund
+
+ Opóźnienie przed odtworzeniem kolejnego odcinka
+ Automatycznie odtwarzaj kolejny odcinek
+ Stosowane jedynie wobec seriali
+
+ Maksimum elementów wiersza na stronie domowej
+ Wybierz domyślne elementy do wyświetlenia, pozostałe zostaną ukryte
+
+ - %d element
+ - %d elementy
+ - %d elementów
+ - %d elementów
+
+ Urządzenie wspiera AC3/Dolby Digital
+ Zaawansowany UI
+ Zmarły(-a)
+ Liczba odtworzeń
+ Odtwarzaj od tej pozycji
+ Playlista
+ Streszczenie
+ Zapisz serial
+
+ Pokaż
+ Pobieranie napisów trwa zbyt długo, może być konieczne ponowne uruchomienie odtwarzacza
+ Harmonogram nagrywania
+ Interfejs użytkownika
+ Skalowanie obrazu
+ Przezroczystość czcionki
+ Adres URL do sprawdzenia aktualizacji
+ Obsada
+ Żaden
+ Nieznany
+ %1$d lat temu
+ Powodzenie
+ Ocena Rodzicielska
+ Dodatki
+
+ - Inne
+
+
+
+
+
+ - Usunięte sceny
+
+
+
+
+ Naciśnij by zmienić stronę
+ Przydatne do debugowania
+ Wyślij logi aplikacji do serwera
+ Poziomy przewijania
+ Obejrzane
+ Logowanie na serwer
+ Umożliwiaj ponowne oglądanie w sekcji następne
+ Próba wysłania raportu błędów do ostatnio połączonego serwera
+ Zapamiętaj wybrane karty
+ Odtwórz muzykę motywu multimediów
+ Przewijanie do przodu
+ Pomiń wstęp
+ Pomiń informacje o poprzednich odcinkach
+ Dopasuj elementy interfejsu nawigacyjnego
+ Cofanie
+ Cofnij gdy wznawiasz odtwarzanie
+ Player odtwarzający
+ Ochrona przed uśpieniem
+
diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml
index 045e125f..89c4b105 100644
--- a/app/src/main/res/values-pt/strings.xml
+++ b/app/src/main/res/values-pt/strings.xml
@@ -1,3 +1,374 @@
+ O Wholphin
+ Favorito
+ Adicionar Servidor
+ Adicionar Utilizador
+ Áudio
+ Cancelar Gravação
+ Cancelar
+ Capítulos
+ Escolher %1$s
+ Eliminar cache de imagens
+ Colecção
+ Colecções
+ Confirmar
+ %.1f segundos
+ Padrão
+ Eliminar
+ Realizado por %1$s
+ Realizador
+ Desativado
+ A transferir…
+ Activado
+ Episódios
+ Externo
+ Favoritos
+ Tamanho
+ Forçado
+ Ir para
+ Gravações Activas
+ Local de Nascimento
+ Bitrate
+ Nascido
+ Cancelar Gravação da Série
+ Ocorreu um erro! Pressiona o botão para enviar o registo para o teu servidor.
+ Continuar a ver
+ Morreu
+ Servidores Detectados
+
+ Introduza o IP ou URL do Servidor
+ Erro ao carregar a colecção %1$s
+ Géneros
+ Ir para a Série
+ Esconder controlos de reprodução
+ Esconder informação de depuração
+ Esconder
+ Início
+ Imediato
+ Introdução
+ #ABCDEFGHIJKLMNOPQRSTUVWXYZ
+ Biblioteca
+ Informação da licença
+ TV em Directo
+ A carregar…
+ Marcar toda a série como reproduzida?
+ Marcar toda a série como não reproduzida?
+ Marcar como não visto
+ Marcar como visto
+ Bitrate Máximo
+ Mais como isto
+ Mais
+ Filmes
+ Nome
+ A Seguir
+ Sem dados
+ Sem resultados
+ Sem gravações programadas
+ Nenhuma atualização disponível
+ Nenhum
+ Créditos finais
+ Pessoas
+ Reproduções
+ Reproduzir a partir daqui
+ Reproduzir
+ Mostrar depuração da reprodução
+ Velocidade da Reprodução
+ Reprodução
+ Lista de Reprodução
+ Listas de Reprodução
+ Pré-visualização
+ Avaliação da Comunidade
+ Avaliação da Crítica
+ Definições de Utilizador
+ Fila
+ Recapitulação
+ Adicionado recentemente em %1$s
+ Adicionado recentemente
+ Gravado Recentemente
+ Lançado Recentemente
+ Recomendado
+ Gravar Programa
+ Gravar Série
+ Desmarcar como favorito
+ Reiniciar
+ Resumir
+ Guardar
+
+ Pesquisar
+ A pesquisar…
+ Selecionar Servidor
+ Selecionar Utilizador
+ Mostrar informação de Depuração
+ Mostrar \'A Seguir\'
+ Mostrar
+ Aleatório
+ Saltar
+ Data Adicionado
+ Data Episódio Adicionado
+ Data Reproduzido
+ Data de Lançamento
+ Nome
+ Aleatória
+ Estúdios
+ Submeter
+ A transferência está a demorar muito, pode ser necessário reiniciar a reprodução
+ Legenda
+ Legendas
+ Sugestões
+ Mudar Servidores
+ Mudar
+ Mais Votados Não Assistidos
+ Trailer
+
+ - Trailers
+ - Trailers
+ - Trailers
+
+ Programação DVR
+ Guia
+ Temporada
+ Temporadas
+ Séries TV
+ Interface
+ Desconhecido
+ Atualizações
+ Versão
+ Escala do Vídeo
+ Vídeo
+ Vídeos
+ Ver em directo
+ %1$d anos
+ Reproduzir com transcodificação
+ Mostrar Relógio
+ Ordem de Emissão
+ Criar nova lista de reprodução
+ Adicionar a lista de reprodução
+ Pausar com um clique
+ Pressione o centro do D-Pad para pausar/reproduzir
+ Itálico
+ Fonte
+ Fundo
+ Êxito
+ Classificação Parental
+ Duração
+ Extras
+
+ - Outro
+ - Outros
+ - Outros
+
+
+ - Nos Bastidores
+ - Nos Bastidores
+ - Nos Bastidores
+
+
+ - Músicas temáticas
+ - Músicas temáticas
+ - Músicas temáticas
+
+
+ - Vídeos Temáticos
+ - Vídeos Temáticos
+ - Vídeos Temáticos
+
+
+ - Clipes
+ - Clipes
+ - Clipes
+
+
+ - Cenas Excluídas
+ - Cenas Excluídas
+ - Cenas Excluídas
+
+
+ - Entrevistas
+ - Entrevistas
+ - Entrevistas
+
+
+ - Cenas
+ - Cenas
+ - Cenas
+
+
+ - Amostras
+ - Amostras
+ - Amostras
+
+
+ - Curta-metragens
+ - Curta-metragens
+ - Curta-metragens
+
+
+ - Curtos
+ - Curtos
+ - Curtos
+
+
+ - %s transferido
+ - %s transferidos
+ - %s transferidos
+
+
+ - %d hora
+ - %d horas
+ - %d horas
+
+
+ - %d item
+ - %d itens
+ - %d itens
+
+
+ - %d segundo
+ - %d segundos
+ - %d segundos
+
+ Publicidade
+ Localização
+ Dispositivo suporta AC3/Dolby Digital
+ Definições Avançadas
+ Interface Avançado
+ Tema da Aplicação
+ Verificar automaticamente se há atualizações
+ Atraso antes de reproduzir \'A Seguir\'
+ Reproduzir automaticamente o próximo
+ Verificar se há atualizações
+ Aplica-se apenas a Séries de TV
+
+ Reprodução direta de legendas ASS
+ Reprodução direta de legendas PGS
+ Conversão sempre em estéreo
+ Usar o módulo decodificador FFmpeg
+ Escala de conteúdo predefinida
+ Instalar atualização
+ Versão instalada
+ Máximo de itens por fila no inicio
+ Escolha os itens padrão a serem exibidos, os demais serão ocultados
+ Personalizar itens da barra de navegação
+ Clique para mudar de página
+ Alternar páginas da barra de navegação ao focar
+ Protecção Contra Inatividade
+ Reproduzir música temática
+ Substituições de reprodução
+ Lembrar separadores selecionados
+ Activar a revisão em \'A seguir\'
+ Passos da barra de pesquisa
+ Útil para depuração
+ Enviar registos da aplicação para o servidor atual
+ Tentará enviar para o último servidor conectado
+ Enviar Relatórios de Falhas
+ Definições
+ Retroceder ao retomar a reprodução
+ Retroceder
+ Comportamento de ignorar anúncios
+ Avançar
+ Comportamento de saltar introdução
+ Comportamento de saltar créditos finais
+ Comportamento de saltar pré-visualizações
+ Comportamento de saltar recapitulação
+ Atualização disponível
+ URL usado para verificar atualizações da aplicação
+ URL de atualizações
+ Tamanho da fonte
+ Cor da fonte
+ Opacidade da fonte
+ Estilo da borda
+ Cor da borda
+ Opacidade do fundo
+ Estilo do fundo
+ Estilo da legenda
+ Cor do fundo
+ Repor
+ Fonte em negrito
+ Motor de reprodução
+ MPV: Usar descodificação por hardware
+ Desative se ocorrerem falhas
+ Opções do MPV
+ Opções do ExoPlayer
+ Saltar Segmento
+ Saltar Publicidade
+ Saltar pré-visualização
+ Saltar Recapitulação
+ Saltar Créditos Finais
+ Saltar Introdução
+ Reproduzido
+ Filtrar
+ Ano
+ Década
+ Remover
+ Dolby Vision
+ Dolby Atmos
+ MPV: Usar gpu-next
+ Editar mpv.conf
+ Anular alterações?
+ Margem
+ Registro detalhado
+ Inserir PIN
+ Entrar automaticamente
+ Iniciar sessão através do servidor
+ Pressione centro para confirmar
+ Exigir PIN para o perfil
+ Confirmar PIN
+ Incorrecto
+ O PIN deve ter 4 dígitos ou mais
+ Irá remover o PIN
+ Tamanho da cache de imagens no disco (MB)
+ Ver opções
+ Colunas
+ Espaçamento
+ Proporção da imagem
+ Mostrar detalhes
+ Tipo de imagem
+
+ Estrelas convidadas
+ Dimensão da borda
+ Mudança da taxa de atualização
+ Automático
+ Usar utilizador/palavra-passe
+ Entrar
+ Mostrar títulos
+ Informação da Multimédia
+ Geral
+ Título
+ Codec
+ Perfil
+ Nível
+ Resolução
+ Anamórfico
+ Intercalado
+ Taxa de fotogramas
+ Profundidade de bit
+ Gama de video
+ Tipo de gama de vídeo
+ Espaço de cor
+ Transferência de cor
+ Cores primárias
+ Formato dos pixeis
+ Quadros de referência
+ NAL
+ Idioma
+ Disposição
+ Canais
+ Taxa de amostragem
+ AVC
+ Sim
+ Não
+ SDR
+ HDR
+ HDR10
+ HDR10+
+ HLG
+ bit
+ Hz
+ Formato
+ Repetir
+ Mostrar primeiro os canais favoritos
+ Ordenar canais por visualizações recentes
+ Codificação de cores para os programas
+ Atraso da legenda
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
new file mode 100644
index 00000000..aeb28c50
--- /dev/null
+++ b/app/src/main/res/values-ru/strings.xml
@@ -0,0 +1,346 @@
+
+
+ Информация
+ В избранное
+ Добавить сервер
+ Добавить Пользователя
+ Аудиодорожка
+ Место рождения
+ Битрейт
+ Рождён
+ Отменить запись
+ Отмена
+ Главы
+ Выберите %1$s
+ Очистить кэш изображений
+ Коллекция
+ Коллекции
+ Оценка сообщества
+ Произошла ошибка! Нажмите клавишу, чтобы отправить файл журнала на ваш сервер.
+ Подтвердить
+ Продолжить просмотр
+ Оценка критиков
+ %.1f секунда
+ По умолчанию
+ Удалить
+ Умер
+ Режиссёр %1$s
+ Режиссёр
+ Обнаруженные серверы
+
+ Скачивание…
+ Введите IP или URL сервера
+ Эпизоды
+ Ошибка при загрузке коллекции %1$s
+ Избранное
+ Устройство поддерживает AC3/Dolby Digital
+ Расширенные настройки
+ Тема оформления
+ Автоматически проверять наличие обновлений
+ Проверить наличие обновлений
+ Всегда преобразовывать в стерео
+ Использовать декодер FFmpeg
+ Размер
+ Жанры
+ Скрыть
+ Вступление
+ Библиотека
+ Информация о лицензии
+ Эфирное ТВ
+ Загрузка…
+ Отметить как непросмотренное
+ Отметить как просмотренное
+ Максимальный битрейт
+ Похожее
+ Фильмы
+ Имя
+ Далее
+ Нет информации
+ Нет результатов
+ Нет доступных обновлений
+ Путь
+ Люди
+ Показать отладочную инфомацию воспроизведения
+ Скорость воспроизведения
+ Воспроизведение
+ Плейлист
+ Плейлисты
+ Настройки профиля
+ Очередь
+ Недавно добавленное в %1$s
+ Недавно добавленное
+ Рекомендовано Вам
+ Убрать из избранного
+ Продолжить
+ Сохранить
+
+ Найти
+ Поиск…
+ Выберите сервер
+ Выберите пользователя
+ Показать отладочную информацию
+ Показать
+ Перемешать
+ Пропустить
+ Дата добавления
+ Дата добавления эпизода
+ Дата воспроизведения
+ Дата выхода
+ Название
+ Случайно
+ Студии
+ Файл субтитров
+ Субтитры
+ Предложения
+ Сменить сервер
+ Сменить
+ Лучшее непросмотренное
+ Трейлер
+
+ - Трейлер
+ - Трейлера
+ - Трейлеров
+ - Трейлеров
+
+ Телегид
+ Сезон
+ Сезоны
+ Телешоу
+ Неизвестно
+ Обновления
+ Версия
+ Масштаб видео
+ Видео
+ Видео
+ %1$d возраст
+ Воспроизвести с перекодированием
+ Показывать часы
+ Создать новый плейлист
+ Добавить в плейлист
+ Пауза одним нажатием
+ Нажмите центральную кнопку для паузы/воспроизведения
+ Шрифт
+ Фоновое изображение
+ Успех
+ Возрастной рейтинг
+ Продолжительность
+ Дополнительно
+
+ - Другое
+ - Других
+ - Других
+ - Других
+
+
+ - За кадром
+
+
+
+
+
+ - Клип
+ - Клипа
+ - Клипов
+ - Клипов
+
+
+ - Удаленная сцена
+ - Удалённые сцены
+ - Удалённых сцен
+ - Удалённых сцен
+
+
+ - Интервью
+ - Интервью
+ - Интервью
+ - Интервью
+
+
+ - Сцена
+ - Сцены
+ - Сцен
+ - Сцен
+
+
+ - %s загрузка
+ - %s загрузок
+ - %s загрузок
+ - %s загрузок
+
+
+ - %d час
+ - %d часа
+ - %d часов
+ - %d часов
+
+
+ - %d секунда
+ - %d секунды
+ - %d секунд
+ - %d секунд
+
+ Активные записи
+ Внешний
+ Перейти
+ Пропустить рекламу
+ Пропустить вступление
+ Недавно записанное
+ Недавно вышедшее
+ Реклама
+ #ABCDEFGHIJKLMNOPQRSTUVWXYZАБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ
+ Отметить все сезоны как воспроизведённые?
+ Отметить все сезоны как невоспроизведённые?
+ Скрыть отладочниую информацию
+ Домашняя страница
+ Отключено
+ Включено
+ Скрывать элементы управления воспроизведением
+ Ещё
+ Нет запланированных записей
+ Воспроизвести
+ Перезапустить
+ Воспроизвести отсюда
+ Смотреть прямой эфир
+ Порядок выхода эпизодов
+ Сброс
+
+ Приглашённые звёзды
+ Соотношение сторон
+ Показать подробности
+ Интервал
+ Dolby Vision
+ Dolby Atmos
+ Редактировать mpv.conf
+ Отменить изменения?
+ Ведите ПИН-код
+ Автоматический вход
+ ПИН-код должен содержать минимум 4 символа
+ Настройки MPV
+ Настройки ExoPlayer
+ Год
+ Десятилетие
+ Убрать
+ MPV: Использовать аппаратное декодирование
+ Фильтр
+ Заключительные титры
+ Превью
+ Отправить
+ Загрузка занимает много времени, возможно, потребуется перезапустить воспроизведение
+ Отменить запись сериала
+ Нет
+ Интерфейс
+ Записать программу
+ Записать сериал
+ Количество воспроизведений
+ Краткое содержание
+ Вход через сервер
+ Нажмите центральную клавишу для подтверждения
+ Требовать ПИН-код профиля
+ Подтвердите ПИН-код
+ Неверно
+ Показать опции
+ Колонки
+ Стиль субтитров
+ Цвет фона
+ Жирный шрифт
+ Стиль фона
+ Тип изображения
+ Удалит ПИН-код
+ Размер кэша изображений на диске (Мбайт)
+ Пропустить краткое содержание
+ Пропустить заключительные титры
+ Подробный журнал
+ Отключите при нестабильной работе
+ MPV: использовать gpu-next
+ Немедленный
+ Отступ
+ Пропустить сегмент
+ Пропустить превью
+ Параметры
+ Перемотать назад при возобновлении воспроизведения
+ Перемотать назад
+ Поведение при пропуске рекламы
+ Перемотка вперёд
+ Поведение при пропуске вступления
+ Поведение при пропуске заключительных титров
+ Поведение при пропуске превью
+ Поведение при пропуске краткого содержания
+ Доступно обновление
+ URL для проверки обновлений приложения
+ Воспроизведено
+ Отправлять отчёты об ошибках
+ Показать Далее
+ Курсив
+ Расширенный интерфейс
+ Задержка перед воспроизведением следующей серии
+ Автовоспроизведение следующей серии
+ Применяется только к Телешоу
+
+ Прямое воспроизведение ASS-субтитров
+ Прямое воспроизведение PGS-субтитров
+ Размер шрифта
+ Цвет щрифта
+ Прозрачность шрифта
+ Стиль обводки
+ Цвет обводки
+ Прозрачность фона
+ Установить обновление
+ Версия приложения
+ Отправить журнал на текущий сервер
+ Совершит попытку отправки на последний подключённый сервер
+ Перейти к сериалу
+ Расписание записей
+ Ссылка для обновлениий
+
+ - %d объект
+ - %d объекта
+ - %d объектов
+ - %d объектов
+
+
+ - Отрывки
+
+
+
+
+
+ - Дополнительные материалы
+
+
+
+
+ Выбор плеера
+ Толщина обводки
+ Масштаб по умолчанию
+ Максимальное количество элементов в ряду на домашней странице
+ Форсированные
+
+ - Саундтреки
+
+
+
+
+
+ - Видеоролики
+
+
+
+
+ Настроить элементы боковой панели
+ Выберите элементы для показа на панели, остальные элементы будут скрыты
+ Воспроизведение фоновой музыки
+ Приоритеты воспроизведения
+ Запоминать выбранные вкладки
+ Разрешить просмотренное в \"Далее\"
+ Интервал перемотки
+ Используется при отладке
+ Смена страниц по нажатию
+ Смена страниц по выделению
+ Остановка воспроизведения при бездействии
+
+ - Короткометражки
+
+
+
+
+
diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml
index 45c38853..c55a7acc 100644
--- a/app/src/main/res/values-sk/strings.xml
+++ b/app/src/main/res/values-sk/strings.xml
@@ -33,7 +33,7 @@
Sťahovanie…
Povolené
- Zadajte IP adresu alebo URL servera vrátane portu
+ Zadajte IP adresu alebo URL servera
Epizódy
Chyba pri načítaní kolekcie %1$s
Externé
@@ -101,4 +101,5 @@
Vybrať server
Vybrať používateľa
Zobraziť informácie o debugu
+ Došlo k chybe! Stlačte tlačidlo, aby ste odoslali logy na svoj server.
diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml
index 21304e53..b7d13882 100644
--- a/app/src/main/res/values-sv/strings.xml
+++ b/app/src/main/res/values-sv/strings.xml
@@ -197,7 +197,7 @@
Markera som ospelad
Markera som spelad
Maximal bithastighet
- Mer liknande detta
+ Liknande titlar
Näst på tur
Ingen data
Inga schemalagda inspelningar
@@ -205,7 +205,7 @@
Ingen
Slutsekvens
Sökväg
- Personer
+ Medverkande
Spelningar
Spela härifrån
Visa felsökningsinfo för uppspelning
@@ -253,4 +253,28 @@
Utseende på text
Bakgrundsfärg
Spelad
+ Filter
+ Konfirmera kod
+ Bildtyp
+ Storlek på bilddiskcache (MB)
+ Visa alternativ
+ Kolumner
+ Avstånd
+ Bildformat
+ Visa detaljer
+ Gästartister
+ Automatisk
+ Använd användarnamn/lösenord
+ Logga in
+ Hoppa framåt
+ Återställ
+ Skippa sammanfattning
+ År
+ Årtionde
+ Ta bort
+ Dolby Vision
+ Marginal
+ Skriv in PIN
+ Logga in automatiskt
+ Logga in via server
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
new file mode 100644
index 00000000..2d317635
--- /dev/null
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -0,0 +1,342 @@
+
+
+ 关于
+ 正在录制
+ 收藏
+ 添加服务器
+ 添加用户
+ 音频
+ 出生地
+ 比特率
+ 出生日期
+ 取消录制
+ 取消剧集录制
+ 取消
+ 章节
+ 选择 %1$s
+ 清除图片缓存
+ 合集
+ 合集
+ 广告
+ 社区评分
+ 发生错误!请按下按钮将日志发送到您的服务器。
+ 确认
+ 继续观看
+ 影评人评分
+ %.1f 秒
+ 默认
+ 删除
+ 去世
+ 由 %1$s 执导
+ 导演
+ 已禁用
+ 已发现的服务器
+
+ 正在下载…
+ 已启用
+ 输入服务器 IP 或 URL
+ 剧集
+ 加载合集 %1$s 时出错
+ 外部
+ 收藏
+ 大小
+ 强制
+ 类型
+ 前往剧集
+ 前往
+ 隐藏播放控件
+ 隐藏调试信息
+ 隐藏
+ 首页
+ 立即
+ 片头
+ #ABCDEFGHIJKLMNOPQRSTUVWXYZ
+ 媒体库
+ 许可信息
+ 电视直播
+ 正在加载…
+ 将整部剧标记为已观看?
+ 将整部剧标记为未观看?
+ 标记为未观看
+ 标记为已观看
+ 最大比特率
+ 类似推荐
+ 更多
+ 电影
+ 名称
+ 即将播放
+ 无数据
+ 无结果
+ 无计划录制
+ 无可用更新
+ 无
+ 片尾
+ 路径
+ 演职人员
+ 播放次数
+ 从此处播放
+ 播放
+ 显示播放调试信息
+ 播放速度
+ 播放
+ 播放列表
+ 播放列表
+ 预览
+ 用户配置设置
+ 队列
+ 前情提要
+ 最近添加至 %1$s
+ 最近添加
+ 最近录制
+ 最近发行
+ 推荐
+ 录制节目
+ 录制剧集
+ 取消收藏
+ 从头播放
+ 继续播放
+ 保存
+
+ 搜索
+ 正在搜索…
+ 选择服务器
+ 选择用户
+ 显示调试信息
+ 显示即将播放
+ 显示
+ 随机播放
+ 跳过
+ 添加日期
+ 剧集添加日期
+ 播放日期
+ 发行日期
+ 名称
+ 随机
+ 制片公司
+ 提交
+ 下载时间过长,可能需要重新开始播放
+ 字幕
+ 字幕
+ 建议
+ 切换服务器
+ 切换
+ 高分未看
+ 预告片
+
+ - 预告片
+
+ DVR 计划
+ 指南
+ 季
+ 季
+ 电视节目
+ 界面
+ 未知
+ 更新
+ 版本
+ 视频比例
+ 视频
+ 视频
+ 观看直播
+ %1$d 岁
+ 转码播放
+ 显示时钟
+ 剧集播出顺序
+ 创建新的播放列表
+ 添加到播放列表
+ 一键暂停
+ 按方向键中心暂停/播放
+ 斜体
+ 字体
+ 背景
+ 成功
+ 年龄分级
+ 播放时长
+ 附加内容
+
+ - 其他
+
+
+ - 幕后花絮
+
+
+ - 主题曲
+
+
+ - 主题视频
+
+
+ - 片段
+
+
+ - 删减片段
+
+
+ - 访谈
+
+
+ - %s 次下载
+
+
+ - %d 小时
+
+
+ - %d 个项目
+
+
+ - %d 秒
+
+
+ - 场景
+
+
+ - 样片
+
+
+ - 特辑
+
+
+ - 短片
+
+ 设备支持 AC3/杜比数字
+ 高级设置
+ 高级用户界面
+ 应用程序主题
+ 自动检查更新
+ 播放下一项前的延迟时间
+ 自动播放下一项
+ 检查更新
+ 仅适用于电视剧
+
+ 直接播放 ASS 字幕
+ 直接播放 PGS 字幕
+ 始终降混为立体声
+ 使用 FFmpeg 解码模块
+ 默认内容比例
+ 安装更新
+ 安装版本
+ 首页每行最大项目数
+ 选择默认显示的项目,其他项目将隐藏
+ 自定义抽屉式导航栏项目
+ 点击切换页面
+ 获得焦点时切换抽屉式导航栏页面
+ 防休眠保护
+ 播放主题音乐
+ 播放覆盖设置
+ 记住所选标签页
+ 在即将播放中重复观看
+ 进度条步进值
+ 用于调试
+ 将应用日志发送到当前服务器
+ 将尝试发送到最后连接的服务器
+ 发送崩溃报告
+ 设置
+ 恢复播放时快退
+ 快退
+ 跳过广告方式
+ 快进
+ 跳过片头方式
+ 跳过片尾方式
+ 跳过预览方式
+ 跳过前情提要方式
+ 有可用更新
+ 用于检查应用更新的 URL
+ 更新 URL
+ 字体大小
+ 字体颜色
+ 字体不透明度
+ 边缘样式
+ 边缘颜色
+ 背景不透明度
+ 背景样式
+ 字幕样式
+ 背景颜色
+ 重置
+ 粗体
+ 播放后端
+ MPV:使用硬件解码
+ 如果遇到崩溃,请禁用
+ MPV 选项
+ ExoPlayer 选项
+ 跳过片段
+ 跳过广告
+ 跳过预览
+ 跳过前情提要
+ 跳过片尾
+ 跳过片头
+ 已观看
+ 筛选
+ 年份
+ 年代
+ 移除
+ 杜比视界
+ 杜比全景声
+ MPV:使用 gpu-next
+ 编辑 mpv.conf
+ 舍弃更改?
+ 边距
+ 详细日志记录
+ 输入 PIN 码
+ 自动登录
+ 通过服务器登录
+ 按中心键确认
+ 配置需要 PIN 码
+ 确认 PIN 码
+ 错误
+ PIN 码必须至少包含 4 个字符
+ 将移除 PIN 码
+ 图片磁盘缓存大小(MB)
+ 查看选项
+ 列
+ 间距
+ 宽高比
+ 显示详情
+ 图片类型
+
+ 客串演员
+ 边缘尺寸
+ 刷新率切换
+ 自动
+ 使用用户名/密码
+ 登录
+ 显示标题
+ 媒体信息
+ 常规
+ 容器格式
+ 标题
+ 编解码器
+ 配置
+ 等级
+ 分辨率
+ 变形宽银幕
+ 隔行扫描
+ 帧速率
+ 位深度
+ 视频范围
+ 视频范围类型
+ 色彩空间
+ 色彩传递
+ 色彩原色
+ 像素格式
+ 参考帧数
+ NAL
+ 语言
+ 布局
+ 频道
+ 采样率
+ AVC
+ 是
+ 否
+ SDR
+ HDR
+ HDR10
+ HDR10+
+ HLG
+ 位
+ Hz
+ 重复
+ 优先显示收藏频道
+ 按最近观看的频道排序
+ 节目颜色标记
+ 字幕延迟
+
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index 249c4d5f..ef0da683 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -11,7 +11,7 @@
更多
電影
名稱
- 即將播放
+ 接下來播放
無資料
無查詢結果
無排程錄影
@@ -24,11 +24,11 @@
播放清單
播放清單
預覽
- 使用者設定
+ 個人化設定
佇列
- 近期新增
- 近期錄製
- 近期發行
+ 最近新增
+ 最近錄製
+ 最近發行
推薦
錄製節目
重頭播放
@@ -41,8 +41,8 @@
顯示
隨機播放
跳過
- 加入日期
- 劇集加入日期
+ 新增日期
+ 單集新增日期
發行日期
名稱
隨機
@@ -61,7 +61,7 @@
版本
畫面比例
影片
- 所有影片
+ 影片
觀看直播
顯示時間
新增播放清單
@@ -147,7 +147,7 @@
位元率
生日
取消錄製
- 取消影集錄製
+ 取消劇集錄製
取消
章節
選擇 %1$s
@@ -172,7 +172,7 @@
下載中…
已啟用
輸入伺服器 IP 位址或 URL
- 劇集
+ 單集
我的最愛
前往劇集
前往
@@ -183,8 +183,8 @@
#ABCDEFGHIJKLMNOPQRSTUVWXYZ
授權資訊
電視直播
- 標記整部劇集為已觀看?
- 標記整部劇集為未觀看?
+ 標記整部電視劇為已觀看?
+ 標記整部電視劇為未觀看?
標記為未觀看
標記為已觀看
最大位元率
@@ -194,7 +194,7 @@
顯示播放偵錯資訊
播放
前情提要
- 最近新增的 %1$s
+ 最近新增於 %1$s
錄製劇集
移出我的最愛
繼續播放
@@ -223,13 +223,13 @@
- %d 秒
播放下一集前的延遲時間
- 僅適用於劇集
-
+ 僅適用於電視劇
+
一律降轉為雙聲道
選取選單時自動切換頁面
防睡保護
- 在即將播放中顯示重播內容
- 顯示即將播放
+ 在接下來播放中顯示重播內容
+ 顯示接下來播放
跳過前情提要
跳過片尾
跳過片頭
@@ -269,4 +269,34 @@
將嘗試傳送至最近連線的伺服器
傳送程式日誌到目前伺服器
有助於偵錯
+ 邊距
+ 詳細日誌記錄
+ 使用 PIN 鍵登入
+ 媒體資訊
+ MPV:使用 gpu-next
+ 編輯 mpv.conf
+ 放棄變更?
+ 輸入 PIN 碼
+ 自動登入
+ 透過伺服器登入
+ 按下中間鍵確認
+ 確認 PIN 碼
+ 錯誤
+ PIN 碼至少需 4 碼
+ 將移除 PIN 碼
+ 圖片快取大小 (MB)
+ 檢視選項
+ 欄數
+ 間距
+ 長寬比
+ 詳細資訊
+ 圖片類型
+
+ 客串演員
+ 邊框大小
+ 畫面更新率切換
+ 自動
+ 使用帳號/密碼
+ 登入
+ 字幕同步
diff --git a/app/src/main/res/values/fa_strings.xml b/app/src/main/res/values/fa_strings.xml
index e7a2b32f..dbfdd541 100644
--- a/app/src/main/res/values/fa_strings.xml
+++ b/app/src/main/res/values/fa_strings.xml
@@ -14,6 +14,8 @@
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 0b72c12a..27d8c907 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -36,7 +36,9 @@
Downloading…
Enabled
+ Ends at %1$s
Enter Server IP or URL
+ Enter server address
Episodes
Error loading collection %1$s
External
@@ -69,6 +71,7 @@
Next Up
No data
No results
+ No servers found
No scheduled recordings
No update available
None
@@ -140,6 +143,7 @@
Watch live
%1$d years old
Play with transcoding
+ Media Information
Show Clock
Aired Episode Order
Create new playlist
@@ -354,6 +358,47 @@
Automatic
Use username/password
Login
+ General
+ Container
+ Title
+ Codec
+ Profile
+ Level
+ Resolution
+ Anamorphic
+ Interlaced
+ Framerate
+ Bit depth
+ Video range
+ Video range type
+ Color space
+ Color transfer
+ Color primaries
+ Pixel format
+ Ref frames
+ NAL
+ Language
+ Layout
+ Channels
+ Sample rate
+ AVC
+ Yes
+ No
+ SDR
+ HDR
+ HDR10
+ HDR10+
+ HLG
+ bit
+ Hz
+ Show titles
+ Repeat
+ Show favorite channels first
+ Sort channels by recently watched
+ Color-code programs
+ Subtitle delay
+ Backdrop style
+
Discover
Request
@@ -444,4 +489,10 @@
+
+ - Image with dynamic color
+ - Image only
+ - None
+
+
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 1c216ed7..467bc93a 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,7 +1,7 @@
[versions]
aboutLibraries = "13.1.0"
acra = "5.13.1"
-agp = "8.13.1"
+agp = "8.13.2"
auto-service = "1.1.1"
autoServiceKsp = "1.2.0"
desugar_jdk_libs = "2.1.5"
@@ -11,7 +11,7 @@ kotlinxCoroutinesCore = "1.10.2"
ksp = "2.3.0"
coreKtx = "1.17.0"
appcompat = "1.7.1"
-composeBom = "2025.12.00"
+composeBom = "2025.12.01"
multiplatformMarkdownRenderer = "0.38.1"
okhttpBom = "5.3.2"
programguide = "1.6.0"
@@ -20,8 +20,8 @@ timber = "5.0.1"
tvFoundation = "1.0.0-alpha12"
tvMaterial = "1.0.1"
lifecycleRuntimeKtx = "2.10.0"
-activityCompose = "1.12.1"
-androidx-media3 = "1.8.0"
+activityCompose = "1.12.2"
+androidx-media3 = "1.9.0"
coil = "3.3.0"
jellyfin-sdk = "1.7.1"
nav3Core = "1.0.0"
@@ -30,10 +30,11 @@ material3AdaptiveNav3 = "1.0.0-alpha03"
protobuf = "0.9.5"
datastore = "1.2.0"
kotlinx-serialization = "1.9.0"
-protobuf-javalite = "4.33.1"
+protobuf-javalite = "4.33.2"
hilt = "2.57.2"
room = "2.8.4"
preferenceKtx = "1.2.1"
+paletteKtx = "1.0.0"
[libraries]
aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" }
@@ -105,6 +106,7 @@ slf4j2-timber = { module = "uk.kulikov:slf4j2-timber", version.ref = "slf4j2Timb
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
androidx-preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "preferenceKtx" }
androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" }
+androidx-palette-ktx = { group = "androidx.palette", name = "palette-ktx", version.ref = "paletteKtx" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
diff --git a/scripts/ffmpeg/build_ffmpeg_decoder.sh b/scripts/ffmpeg/build_ffmpeg_decoder.sh
index 7dd28133..59718b52 100755
--- a/scripts/ffmpeg/build_ffmpeg_decoder.sh
+++ b/scripts/ffmpeg/build_ffmpeg_decoder.sh
@@ -13,7 +13,7 @@ PROJECT_ROOT="$(realpath "${SCRIPT_DIR}/../../")"
# Config
ANDROID_ABI=21
-ENABLED_DECODERS=(dca ac3 eac3 mlp truehd)
+ENABLED_DECODERS=(dca ac3 eac3 mlp truehd flac alac pcm_mulaw pcm_alaw mp3)
FFMPEG_BRANCH="release/6.0"
# Path configs
diff --git a/scripts/mpv/README.md b/scripts/mpv/README.md
index 80eeb407..4d41c03a 100644
--- a/scripts/mpv/README.md
+++ b/scripts/mpv/README.md
@@ -4,4 +4,16 @@ This scripts are adapted from https://github.com/mpv-android/mpv-android/tree/ae
## Instructions
-TODO
+```bash
+cd scripts/mpv
+./get_dependencies
+
+export NDK_PATH=... # Such as ~/Library/Android/sdk/ndk/29.0.14206865
+# Build arm64
+PATH="$PATH:$NDK_PATH/toolchains/llvm/prebuilt/darwin-x86_64/bin" ./buildall.sh --clean --arch arm64 mpv
+# Build arm32
+PATH="$PATH:$NDK_PATH/toolchains/llvm/prebuilt/darwin-x86_64/bin" ./buildall.sh mpv
+
+cd ../..
+env PREFIX32="$(realpath scripts/mpv/prefix/armv7l)" PREFIX64="$(realpath scripts/mpv/prefix/arm64)" "$NDK_PATH/ndk-build" -C app/src/main -j && cp -fr app/src/main/libs/ app/src/main/jnilibs/
+```