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 -
-
Current Release Translation status +
+ +Get Wholphin on Google Play + + +Get Wholphin on Amazon AppStore + + +

0_3_5_home @@ -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/ +```