Merge branch 'main' into fea/jellyseerr

This commit is contained in:
Damontecres 2025-12-20 15:06:22 -05:00
commit 99d1ccf7e7
No known key found for this signature in database
130 changed files with 8161 additions and 1622 deletions

12
.github/pull_request_template.md vendored Normal file
View file

@ -0,0 +1,12 @@
## Description
<!-- Describe the changes in detail -->
### Related issues
<!-- If this is a new feature or a change, there must be a discussion in an issue first, then reference the issue here -->
<!-- If fixing a bug, reference the bug issue here, or describe the bug, including steps to reproduce -->
### Screenshots
<!-- Please include screenshots if the PR alters any UI elements -->
### AI/LLM usage
<!-- If you used any AI/LLM assistance, please describe where in the code and how you tested it -->

4
.gitignore vendored
View file

@ -53,7 +53,9 @@ app/libs/
ffmpeg_decoder/ ffmpeg_decoder/
app/release/ app/release/
app/debug/ app/debug/
<<<<<<< HEAD .vscode
.kotlin
docs/
# mpv # mpv
app/src/main/obj/ app/src/main/obj/

View file

@ -18,3 +18,8 @@ repos:
entry: val DEBUG = true entry: val DEBUG = true
language: pygrep language: pygrep
files: '.+\.kt' files: '.+\.kt'
- id: check-debug-logging-enabled
name: Check debug enabled
entry: debugLogging = true
language: pygrep
files: '.+\.kt'

View file

@ -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. 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: Code is split into several packages:
- `data` - app-specific data models and services - `data` - app-specific data models and services

View file

@ -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). 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).
<p align="center"> <p align="center">
<a href="https://github.com/damontecres/Wholphin/issues/303"><b>Help get Wholphin listed on the Play Store</b></a>
<br/>
<br/>
<a href="https://github.com/damontecres/Wholphin/releases"> <a href="https://github.com/damontecres/Wholphin/releases">
<img alt="Current Release" src="https://img.shields.io/github/release/damontecres/wholphin.svg"/> <img alt="Current Release" src="https://img.shields.io/github/release/damontecres/wholphin.svg"/>
</a> </a>
<a href="https://translate.codeberg.org/engage/wholphin/"> <a href="https://translate.codeberg.org/engage/wholphin/">
<img src="https://translate.codeberg.org/widget/wholphin/wholphin/svg-badge.svg" alt="Translation status" /> <img src="https://translate.codeberg.org/widget/wholphin/wholphin/svg-badge.svg" alt="Translation status" />
</a> </a>
<br/>
<a href="https://play.google.com/store/apps/details?id=com.github.damontecres.wholphin">
<img width="180" alt="Get Wholphin on Google Play" src="https://github.com/user-attachments/assets/2550a4cb-ce46-47a1-ae24-f33a169234b7"/>
</a>
<a href="https://www.amazon.com/gp/product/B0G8RQQR9T/ref=mas_pm_wholphin">
<img width="180" alt="Get Wholphin on Amazon AppStore" src="https://github.com/user-attachments/assets/1f3a3b26-4b4f-44b1-9741-f4c895c8a53b"/>
</a>
</p> </p>
<img width="1280" height="720" alt="0_3_5_home" src="https://github.com/user-attachments/assets/a485c015-ec21-442d-a757-1f18381bf799" /> <img width="1280" height="720" alt="0_3_5_home" src="https://github.com/user-attachments/assets/a485c015-ec21-442d-a757-1f18381bf799" />
@ -24,12 +30,12 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin
### User interface ### 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 - Option to combine Continue Watching & Next Up rows
- Show Movie/TV Show titles when browsing libraries - Show Movie/TV Show titles when browsing libraries
- Play theme music, if available - 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 - Customize layout grids for libraries
- Access all your favorites quickly from the nav drawer
- Multiple app color themes - Multiple app color themes
### Playback ### 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 - Optionally skip back a few seconds when resuming playback
- Live TV & DVR support - Live TV & DVR support
- Auto play next episodes with pass out protection - Auto play next episodes with pass out protection
- Option for automatic refresh rate switching on supported displays
- Trickplay support
- Other (subjective) enhancements: - Other (subjective) enhancements:
- Subtly show playback position along the bottom of the screen while seeking w/ D-Pad - 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 - Force Continue Watching & Next Up TV episodes to use their Series posters

View file

@ -262,6 +262,7 @@ dependencies {
implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.preference.ktx) implementation(libs.androidx.preference.ktx)
implementation(libs.androidx.room.testing) implementation(libs.androidx.room.testing)
implementation(libs.androidx.palette.ktx)
ksp(libs.androidx.room.compiler) ksp(libs.androidx.room.compiler)
ksp(libs.hilt.android.compiler) ksp(libs.hilt.android.compiler)

View file

@ -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')"
]
}
}

View file

@ -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')"
]
}
}

View file

@ -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.ApplicationContent
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
import com.github.damontecres.wholphin.util.DebugLogTree import com.github.damontecres.wholphin.util.DebugLogTree
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -222,13 +223,15 @@ class MainActivity : AppCompatActivity() {
) )
} }
} }
ApplicationContent( ProvideLocalClock {
user = current?.user, ApplicationContent(
server = current?.server, user = current?.user,
navigationManager = navigationManager, server = current?.server,
preferences = preferences, navigationManager = navigationManager,
modifier = Modifier.fillMaxSize(), preferences = preferences,
) modifier = Modifier.fillMaxSize(),
)
}
} }
} }
} }

View file

@ -9,10 +9,12 @@ import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase
import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.data.model.ItemPlayback 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.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem 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 com.github.damontecres.wholphin.ui.components.ViewOptions
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.ItemSortBy
@ -22,8 +24,16 @@ import timber.log.Timber
import java.util.UUID import java.util.UUID
@Database( @Database(
entities = [JellyfinServer::class, JellyfinUser::class, ItemPlayback::class, NavDrawerPinnedItem::class, LibraryDisplayInfo::class], entities = [
version = 10, JellyfinServer::class,
JellyfinUser::class,
ItemPlayback::class,
NavDrawerPinnedItem::class,
LibraryDisplayInfo::class,
PlaybackLanguageChoice::class,
ItemTrackModification::class,
],
version = 12,
exportSchema = true, exportSchema = true,
autoMigrations = [ autoMigrations = [
AutoMigration(3, 4), AutoMigration(3, 4),
@ -33,6 +43,8 @@ import java.util.UUID
AutoMigration(7, 8), AutoMigration(7, 8),
AutoMigration(8, 9), AutoMigration(8, 9),
AutoMigration(9, 10), AutoMigration(9, 10),
AutoMigration(10, 11),
AutoMigration(11, 12),
], ],
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
@ -44,6 +56,8 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun serverPreferencesDao(): ServerPreferencesDao abstract fun serverPreferencesDao(): ServerPreferencesDao
abstract fun libraryDisplayInfoDao(): LibraryDisplayInfoDao abstract fun libraryDisplayInfoDao(): LibraryDisplayInfoDao
abstract fun playbackLanguageChoiceDao(): PlaybackLanguageChoiceDao
} }
class Converters { class Converters {

View file

@ -5,6 +5,7 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import com.github.damontecres.wholphin.data.model.ItemPlayback 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 com.github.damontecres.wholphin.data.model.JellyfinUser
import java.util.UUID import java.util.UUID
@ -26,4 +27,20 @@ interface ItemPlaybackDao {
@Query("SELECT * from ItemPlayback WHERE userId=:userId") @Query("SELECT * from ItemPlayback WHERE userId=:userId")
fun getItems(userId: Int): List<ItemPlayback> fun getItems(userId: Int): List<ItemPlayback>
@Query("SELECT * FROM ItemTrackModification WHERE userId=:userId AND itemId=:itemId")
suspend fun getTrackModifications(
userId: Int,
itemId: UUID,
): List<ItemTrackModification>
@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
} }

View file

@ -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.BaseItem
import com.github.damontecres.wholphin.data.model.ItemPlayback 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.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.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.jellyfin.sdk.model.serializer.toUUIDOrNull
@ -13,6 +17,7 @@ import timber.log.Timber
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.time.Duration
@Singleton @Singleton
class ItemPlaybackRepository class ItemPlaybackRepository
@ -20,47 +25,60 @@ class ItemPlaybackRepository
constructor( constructor(
val serverRepository: ServerRepository, val serverRepository: ServerRepository,
val itemPlaybackDao: ItemPlaybackDao, val itemPlaybackDao: ItemPlaybackDao,
private val streamChoiceService: StreamChoiceService,
) { ) {
fun getSelectedTracks( suspend fun getSelectedTracks(
itemId: UUID, itemId: UUID,
item: BaseItem, item: BaseItem,
prefs: UserPreferences,
): ChosenStreams? = ): ChosenStreams? =
serverRepository.currentUser.value?.let { user -> serverRepository.currentUser.value?.let { user ->
val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId) val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId)
if (itemPlayback != null) { val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
Timber.v("Got itemPlayback for %s", itemId) Timber.v("For ${item.id}: itemPlayback=${itemPlayback != null}, plc=${plc != null}")
getChosenItemFromPlayback(item, itemPlayback) return getChosenItemFromPlayback(item, itemPlayback, plc, prefs)
} else {
null
}
} }
fun getChosenItemFromPlayback( fun getChosenItemFromPlayback(
item: BaseItem, item: BaseItem,
itemPlayback: ItemPlayback, itemPlayback: ItemPlayback?,
plc: PlaybackLanguageChoice?,
prefs: UserPreferences,
): ChosenStreams? { ): ChosenStreams? {
val source = 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) { if (source != null) {
val audioStream = val audioStream =
if (itemPlayback.audioIndexEnabled) { streamChoiceService.chooseAudioStream(
source.mediaStreams?.firstOrNull { it.index == itemPlayback.audioIndex } candidates =
} else { source.mediaStreams
null ?.filter { it.type == MediaStreamType.AUDIO }
} .orEmpty(),
itemPlayback = itemPlayback,
playbackLanguageChoice = plc,
prefs = prefs,
)
val subtitleStream = val subtitleStream =
if (itemPlayback.subtitleIndexEnabled) { streamChoiceService.chooseSubtitleStream(
source.mediaStreams?.firstOrNull { it.index == itemPlayback.subtitleIndex } audioStream = audioStream,
} else { candidates =
null source.mediaStreams
} ?.filter { it.type == MediaStreamType.SUBTITLE }
.orEmpty(),
itemPlayback = itemPlayback,
playbackLanguageChoice = plc,
prefs = prefs,
)
return ChosenStreams( return ChosenStreams(
itemPlayback, itemPlayback = itemPlayback,
item.id, plc = plc,
source.id?.toUUIDOrNull(), itemId = item.id,
audioStream, source = source,
subtitleStream, videoStream = source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO },
itemPlayback.subtitleIndex == TrackIndex.DISABLED, audioStream = audioStream,
subtitleStream = subtitleStream,
subtitlesDisabled = itemPlayback?.subtitleIndex == TrackIndex.DISABLED,
) )
} else { } else {
return null return null
@ -89,22 +107,58 @@ class ItemPlaybackRepository
itemPlayback: ItemPlayback?, itemPlayback: ItemPlayback?,
trackIndex: Int, trackIndex: Int,
type: MediaStreamType, type: MediaStreamType,
) = serverRepository.currentUser.value?.let { user -> ): ItemPlayback =
var toSave = serverRepository.current.value!!.let { current ->
itemPlayback ?: ItemPlayback( val source =
userId = user.rowId, itemPlayback?.sourceId?.let { sourceId ->
itemId = item.id, item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == sourceId }
sourceId = chooseSource(item.data, null)?.id?.toUUIDOrNull(), } ?: streamChoiceService.chooseSource(item.data, null)
) if (source == null) {
toSave = Timber.w("Could not find media source for ${item.id}")
when (type) { throw IllegalArgumentException("Could not find media source for ${item.id}")
MediaStreamType.AUDIO -> toSave.copy(audioIndex = trackIndex)
MediaStreamType.SUBTITLE -> toSave.copy(subtitleIndex = trackIndex)
else -> toSave
} }
Timber.v("Saving track selection %s", toSave) var toSave =
saveItemPlayback(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 * 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) val id = itemPlaybackDao.saveItem(toSave)
return toSave.copy(rowId = id) 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( data class ChosenStreams(
val itemPlayback: ItemPlayback, val itemPlayback: ItemPlayback?,
val plc: PlaybackLanguageChoice?,
val itemId: UUID, val itemId: UUID,
val sourceId: UUID?, val source: MediaSourceInfo,
val videoStream: MediaStream?,
val audioStream: MediaStream?, val audioStream: MediaStream?,
val subtitleStream: MediaStream?, val subtitleStream: MediaStream?,
val subtitlesDisabled: Boolean, val subtitlesDisabled: Boolean,

View file

@ -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
}

View file

@ -126,7 +126,9 @@ class ServerRepository
} }
if (serverAndUsers != null) { if (serverAndUsers != null) {
val user = serverAndUsers.users.firstOrNull { it.id == userId } 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) changeUser(serverAndUsers.server, user)
return true return true
} }

View file

@ -1,8 +1,10 @@
package com.github.damontecres.wholphin.data.model 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.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.formatDateTime import com.github.damontecres.wholphin.ui.formatDateTime
import com.github.damontecres.wholphin.ui.nav.Destination 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.seasonEpisode
import com.github.damontecres.wholphin.ui.seasonEpisodePadded import com.github.damontecres.wholphin.ui.seasonEpisodePadded
import com.github.damontecres.wholphin.ui.seriesProductionYears import com.github.damontecres.wholphin.ui.seriesProductionYears
@ -18,8 +20,14 @@ import kotlin.time.Duration
data class BaseItem( data class BaseItem(
val data: BaseItemDto, val data: BaseItemDto,
val useSeriesForPrimary: Boolean, val useSeriesForPrimary: Boolean,
) { ) : CardGridItem {
val id get() = data.id 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 val type get() = data.type
@ -47,6 +55,9 @@ data class BaseItem(
} }
} }
@Transient
val aspectRatio: Float? = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 }
@Transient @Transient
val indexNumber = data.indexNumber ?: dateAsIndex() val indexNumber = data.indexNumber ?: dateAsIndex()

View file

@ -7,19 +7,10 @@ import androidx.room.ForeignKey
import androidx.room.Ignore import androidx.room.Ignore
import androidx.room.Index import androidx.room.Index
import androidx.room.PrimaryKey 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.Serializable
import kotlinx.serialization.Transient import kotlinx.serialization.Transient
import kotlinx.serialization.UseSerializers 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.UUIDSerializer
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID import java.util.UUID
@Entity( @Entity(
@ -53,122 +44,6 @@ data class ItemPlayback(
val subtitleIndexEnabled = subtitleIndex >= 0 val subtitleIndexEnabled = subtitleIndex >= 0
} }
/**
* Returns the [MediaSourceInfo] with the highest video resolution
*/
fun chooseSource(sources: List<MediaSourceInfo>?) =
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<MediaStream>,
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<MediaStream> { 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<MediaStream>,
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 { object TrackIndex {
/** /**
* The user has not explicitly specified a track to use * The user has not explicitly specified a track to use

View file

@ -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,
)

View file

@ -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,
)

View file

@ -659,6 +659,19 @@ sealed interface AppPreference<Pref, T> {
summaryOff = R.string.disabled, summaryOff = R.string.disabled,
) )
val BackdropStylePref =
AppChoicePreference<AppPreferences, BackdropStyle>(
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 = val OneClickPause =
AppSwitchPreference<AppPreferences>( AppSwitchPreference<AppPreferences>(
title = R.string.one_click_pause, title = R.string.one_click_pause,
@ -784,6 +797,51 @@ sealed interface AppPreference<Pref, T> {
} }
}, },
) )
val LiveTvShowHeader =
AppSwitchPreference<AppPreferences>(
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<AppPreferences>(
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<AppPreferences>(
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<AppPreferences>(
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 // Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418
// AppPreference.NavDrawerSwitchOnFocus, // AppPreference.NavDrawerSwitchOnFocus,
AppPreference.ControllerTimeout, AppPreference.ControllerTimeout,
AppPreference.BackdropStylePref,
), ),
), ),
) )
@ -948,6 +1007,14 @@ val advancedPreferences =
) )
} }
val liveTvPreferences =
listOf(
AppPreference.LiveTvShowHeader,
AppPreference.LiveTvFavoriteChannelsBeginning,
AppPreference.LiveTvChannelSortByWatched,
AppPreference.LiveTvColorCodePrograms,
)
data class AppSwitchPreference<Pref>( data class AppSwitchPreference<Pref>(
@get:StringRes override val title: Int, @get:StringRes override val title: Int,
override val defaultValue: Boolean, override val defaultValue: Boolean,

View file

@ -91,6 +91,7 @@ class AppPreferencesSerializer
navDrawerSwitchOnFocus = navDrawerSwitchOnFocus =
AppPreference.NavDrawerSwitchOnFocus.defaultValue AppPreference.NavDrawerSwitchOnFocus.defaultValue
showClock = AppPreference.ShowClock.defaultValue showClock = AppPreference.ShowClock.defaultValue
backdropStyle = AppPreference.BackdropStylePref.defaultValue
subtitlesPreferences = subtitlesPreferences =
SubtitlePreferences SubtitlePreferences
@ -98,6 +99,19 @@ class AppPreferencesSerializer
.apply { .apply {
resetSubtitles() resetSubtitles()
}.build() }.build()
liveTvPreferences =
LiveTvPreferences
.newBuilder()
.apply {
showHeader = AppPreference.LiveTvShowHeader.defaultValue
favoriteChannelsAtBeginning =
AppPreference.LiveTvFavoriteChannelsBeginning.defaultValue
sortByRecentlyWatched =
AppPreference.LiveTvChannelSortByWatched.defaultValue
colorCodePrograms =
AppPreference.LiveTvColorCodePrograms.defaultValue
}.build()
}.build() }.build()
advancedPreferences = advancedPreferences =
@ -155,6 +169,11 @@ inline fun AppPreferences.updateSubtitlePreferences(block: SubtitlePreferences.B
subtitlesPreferences = subtitlesPreferences.toBuilder().apply(block).build() 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 = inline fun AppPreferences.updateAdvancedPreferences(block: AdvancedPreferences.Builder.() -> Unit): AppPreferences =
update { update {
advancedPreferences = advancedPreferences.toBuilder().apply(block).build() advancedPreferences = advancedPreferences.toBuilder().apply(block).build()

View file

@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.update import com.github.damontecres.wholphin.preferences.update
import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences 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.updateMpvOptions
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences 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
}
}
}
}
} }

View file

@ -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<AppPreferences>,
) {
private val extractedColorCache = LruCache<String, ExtractedColors>(50)
private val _backdropFlow = MutableStateFlow<BackdropResult>(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)
}
}

View file

@ -31,7 +31,7 @@ class ImageUrlService
imageType: ImageType, imageType: ImageType,
fillWidth: Int? = null, fillWidth: Int? = null,
fillHeight: Int? = null, fillHeight: Int? = null,
): String? = ): String =
when (imageType) { when (imageType) {
ImageType.BACKDROP, ImageType.BACKDROP,
ImageType.LOGO, ImageType.LOGO,
@ -145,6 +145,8 @@ class ImageUrlService
imageIndex = imageIndex, imageIndex = imageIndex,
) )
fun getUserImageUrl(userId: UUID) = api.imageApi.getUserImageUrl(userId)
/** /**
* Just a convenient way to get the image URL and remember it * Just a convenient way to get the image URL and remember it
*/ */

View file

@ -3,9 +3,12 @@ package com.github.damontecres.wholphin.services
import android.content.Context import android.content.Context
import android.hardware.display.DisplayManager import android.hardware.display.DisplayManager
import android.os.Build import android.os.Build
import android.os.Handler
import android.os.Looper
import android.view.Display import android.view.Display
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.EqualityMutableLiveData import com.github.damontecres.wholphin.util.EqualityMutableLiveData
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -29,6 +32,8 @@ class RefreshRateService
private val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY) private val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
private val originalMode = display.mode private val originalMode = display.mode
val displayModes get() = display.supportedModes
private val _refreshRateMode = EqualityMutableLiveData<Display.Mode>(originalMode) private val _refreshRateMode = EqualityMutableLiveData<Display.Mode>(originalMode)
val refreshRateMode: LiveData<Display.Mode> = _refreshRateMode val refreshRateMode: LiveData<Display.Mode> = _refreshRateMode
@ -57,12 +62,18 @@ class RefreshRateService
Timber.i("Found display mode: %s, current=${display.mode}", targetMode) Timber.i("Found display mode: %s, current=${display.mode}", targetMode)
if (targetMode != null && targetMode != display.mode) { if (targetMode != null && targetMode != display.mode) {
val listener = Listener(display.displayId) val listener = Listener(display.displayId)
displayManager.registerDisplayListener(listener, null) displayManager.registerDisplayListener(
listener,
Handler(Looper.myLooper() ?: Looper.getMainLooper()),
)
_refreshRateMode.setValueOnMain(targetMode) _refreshRateMode.setValueOnMain(targetMode)
try { try {
listener.latch.await(5, TimeUnit.SECONDS) if (!listener.latch.await(5, TimeUnit.SECONDS)) {
} catch (_: InterruptedException) { Timber.w("Timed out waiting for display change")
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 targetRate = (targetMode.refreshRate * 100).roundToInt()
val isSeamless = val isSeamless =

View file

@ -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<MediaSourceInfo>?) =
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<MediaStream>,
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<MediaStream> { 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<MediaStream>,
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
}
}
}
}
}

View file

@ -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<AppPreferences>,
) {
suspend fun getCurrent(): UserPreferences =
serverRepository.currentUserDto.value!!.configuration.let { userConfig ->
val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
UserPreferences(
appPrefs,
userConfig ?: DefaultUserConfiguration,
)
}
}

View file

@ -1,8 +1,6 @@
package com.github.damontecres.wholphin.services.hilt package com.github.damontecres.wholphin.services.hilt
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.provider.Settings
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
@ -24,6 +22,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.android.androidDevice
import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder
import org.jellyfin.sdk.api.okhttp.OkHttpFactory import org.jellyfin.sdk.api.okhttp.OkHttpFactory
import org.jellyfin.sdk.createJellyfin import org.jellyfin.sdk.createJellyfin
@ -61,14 +60,7 @@ object AppModule {
@Singleton @Singleton
fun deviceInfo( fun deviceInfo(
@ApplicationContext context: Context, @ApplicationContext context: Context,
): DeviceInfo = ): DeviceInfo = androidDevice(context)
DeviceInfo(
id = @SuppressLint("HardwareIds") Settings.Secure.getString(
context.contentResolver,
Settings.Secure.ANDROID_ID,
),
name = Settings.Global.getString(context.contentResolver, Settings.Global.DEVICE_NAME),
)
@StandardOkHttpClient @StandardOkHttpClient
@Provides @Provides

View file

@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.data.ItemPlaybackDao
import com.github.damontecres.wholphin.data.JellyfinServerDao import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
import com.github.damontecres.wholphin.data.Migrations 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.data.ServerPreferencesDao
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
@ -56,6 +57,10 @@ object DatabaseModule {
@Singleton @Singleton
fun libraryDisplayInfoDao(db: AppDatabase): LibraryDisplayInfoDao = db.libraryDisplayInfoDao() fun libraryDisplayInfoDao(db: AppDatabase): LibraryDisplayInfoDao = db.libraryDisplayInfoDao()
@Provides
@Singleton
fun playbackLanguageChoiceDao(db: AppDatabase): PlaybackLanguageChoiceDao = db.playbackLanguageChoiceDao()
@Provides @Provides
@Singleton @Singleton
fun userPreferencesDataStore( fun userPreferencesDataStore(

View file

@ -8,8 +8,12 @@ import coil3.compose.setSingletonImageLoaderFactory
import coil3.disk.DiskCache import coil3.disk.DiskCache
import coil3.disk.directory import coil3.disk.directory
import coil3.memory.MemoryCache import coil3.memory.MemoryCache
import coil3.network.CacheStrategy
import coil3.network.NetworkRequest
import coil3.network.NetworkResponse
import coil3.network.cachecontrol.CacheControlCacheStrategy import coil3.network.cachecontrol.CacheControlCacheStrategy
import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.Options
import coil3.request.crossfade import coil3.request.crossfade
import coil3.util.DebugLogger import coil3.util.DebugLogger
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@ -67,10 +71,39 @@ fun CoilConfig(
.components { .components {
add( add(
OkHttpNetworkFetcherFactory( OkHttpNetworkFetcherFactory(
cacheStrategy = { CacheControlCacheStrategy() }, cacheStrategy = { WholphinCacheStrategy(CacheControlCacheStrategy()) },
callFactory = { client }, callFactory = { client },
), ),
) )
}.build() }.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)
}

View file

@ -68,6 +68,7 @@ val SlimItemFields =
object Cards { object Cards {
val height2x3 = 172.dp val height2x3 = 172.dp
val playedPercentHeight = 6.dp val playedPercentHeight = 6.dp
val serverUserCircle = height2x3 * .75f
} }
object AspectRatios { object AspectRatios {
@ -75,6 +76,8 @@ object AspectRatios {
const val FOUR_THREE = 4f / 3f const val FOUR_THREE = 4f / 3f
const val TALL = 2f / 3f const val TALL = 2f / 3f
const val SQUARE = 1f const val SQUARE = 1f
const val MIN = TALL
} }
enum class AspectRatio( enum class AspectRatio(

View file

@ -64,7 +64,7 @@ fun EpisodeCard(
} else { } else {
focusedAfterDelay = false focusedAfterDelay = false
} }
val aspectRatio = dto?.primaryImageAspectRatio?.toFloat() ?: AspectRatios.TALL val aspectRatio = item?.aspectRatio?.coerceAtLeast(AspectRatios.MIN) ?: AspectRatios.MIN
val width = imageHeight * aspectRatio val width = imageHeight * aspectRatio
val height = imageWidth * (1f / aspectRatio) val height = imageWidth * (1f / aspectRatio)
Column( Column(

View file

@ -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),
)
}
}

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.ui.cards package com.github.damontecres.wholphin.ui.cards
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
@ -44,6 +45,7 @@ fun GridCard(
imageAspectRatio: Float = AspectRatios.TALL, imageAspectRatio: Float = AspectRatios.TALL,
imageContentScale: ContentScale = ContentScale.Fit, imageContentScale: ContentScale = ContentScale.Fit,
imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY, imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
showTitle: Boolean = true,
) { ) {
val dto = item?.data val dto = item?.data
val focused by interactionSource.collectIsFocusedAsState() val focused by interactionSource.collectIsFocusedAsState()
@ -94,38 +96,40 @@ fun GridCard(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(imageAspectRatio) .aspectRatio(imageAspectRatio.coerceAtLeast(AspectRatios.MIN))
.background(MaterialTheme.colorScheme.surfaceVariant), .background(MaterialTheme.colorScheme.surfaceVariant),
) )
} }
Column( AnimatedVisibility(showTitle) {
verticalArrangement = Arrangement.spacedBy(0.dp), Column(
modifier = verticalArrangement = Arrangement.spacedBy(0.dp),
Modifier
.padding(bottom = spaceBelow)
.fillMaxWidth(),
) {
Text(
text = item?.title ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
overflow = TextOverflow.Ellipsis,
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .padding(bottom = spaceBelow)
.padding(horizontal = 4.dp) .fillMaxWidth(),
.enableMarquee(focusedAfterDelay), ) {
) Text(
Text( text = item?.title ?: "",
text = item?.subtitle ?: "", maxLines = 1,
maxLines = 1, textAlign = TextAlign.Center,
textAlign = TextAlign.Center, overflow = TextOverflow.Ellipsis,
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 4.dp) .padding(horizontal = 4.dp)
.enableMarquee(focusedAfterDelay), .enableMarquee(focusedAfterDelay),
) )
Text(
text = item?.subtitle ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.enableMarquee(focusedAfterDelay),
)
}
} }
} }
} }

View file

@ -113,8 +113,7 @@ fun BannerItemRow(
name = title, name = title,
item = item, item = item,
aspectRatio = aspectRatio =
aspectRatioOverride ?: item?.data?.primaryImageAspectRatio?.toFloat() aspectRatioOverride ?: item?.aspectRatio ?: AspectRatios.WIDE,
?: AspectRatios.WIDE,
cornerText = item?.data?.indexNumber?.let { "E$it" }, cornerText = item?.data?.indexNumber?.let { "E$it" },
played = item?.data?.userData?.played ?: false, played = item?.data?.userData?.played ?: false,
playPercent = item?.data?.userData?.playedPercentage ?: 0.0, playPercent = item?.data?.userData?.playedPercentage ?: 0.0,

View file

@ -46,7 +46,7 @@ fun SeasonCard(
imageWidth: Dp = Dp.Unspecified, imageWidth: Dp = Dp.Unspecified,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
showImageOverlay: Boolean = false, showImageOverlay: Boolean = false,
aspectRatio: Float = item?.data?.primaryImageAspectRatio?.toFloat() ?: AspectRatios.TALL, aspectRatio: Float = item?.aspectRatio ?: AspectRatios.TALL,
) { ) {
val imageUrlService = LocalImageUrlService.current val imageUrlService = LocalImageUrlService.current
val density = LocalDensity.current val density = LocalDensity.current
@ -139,8 +139,9 @@ fun SeasonCard(
} else { } else {
focusedAfterDelay = false focusedAfterDelay = false
} }
val width = imageHeight * aspectRatio val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
val height = imageWidth * (1f / aspectRatio) val width = imageHeight * aspectRationToUse
val height = imageWidth * (1f / aspectRationToUse)
Column( Column(
verticalArrangement = Arrangement.spacedBy(spaceBetween), verticalArrangement = Arrangement.spacedBy(spaceBetween),
modifier = modifier.size(width, height), modifier = modifier.size(width, height),
@ -149,7 +150,7 @@ fun SeasonCard(
modifier = modifier =
Modifier Modifier
.size(imageWidth, imageHeight) .size(imageWidth, imageHeight)
.aspectRatio(aspectRatio), .aspectRatio(aspectRationToUse),
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
interactionSource = interactionSource, interactionSource = interactionSource,

View file

@ -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.GetItemsFilterOverride
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.AspectRatios 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.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome 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.main.HomePageHeader
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.scale import com.github.damontecres.wholphin.ui.playback.scale
@ -123,6 +125,7 @@ class CollectionFolderViewModel
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val libraryDisplayInfoDao: LibraryDisplayInfoDao, private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
private val favoriteWatchManager: FavoriteWatchManager, private val favoriteWatchManager: FavoriteWatchManager,
private val backdropService: BackdropService,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
) : ItemViewModel(api) { ) : ItemViewModel(api) {
val loading = MutableLiveData<LoadingState>(LoadingState.Loading) val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
@ -202,6 +205,9 @@ class CollectionFolderViewModel
this.viewOptions.value = viewOptions this.viewOptions.value = viewOptions
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
saveLibraryDisplayInfo(viewOptions = viewOptions) saveLibraryDisplayInfo(viewOptions = viewOptions)
if (!viewOptions.showDetails) {
backdropService.clearBackdrop()
}
} }
} }
@ -246,13 +252,26 @@ class CollectionFolderViewModel
this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection
this@CollectionFolderViewModel.filter.value = filter this@CollectionFolderViewModel.filter.value = filter
} }
val newPager = createPager(sortAndDirection, recursive, filter, useSeriesForPrimary) try {
newPager.init() val newPager =
if (newPager.isNotEmpty()) newPager.getBlocking(0) createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init()
withContext(Dispatchers.Main) { if (newPager.isNotEmpty()) newPager.getBlocking(0)
pager.value = newPager withContext(Dispatchers.Main) {
loading.value = LoadingState.Success pager.value = newPager
backgroundLoading.value = LoadingState.Success 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) favoriteWatchManager.setFavorite(itemId, favorite)
(pager.value as? ApiRequestPager<*>)?.refreshItem(position, itemId) (pager.value as? ApiRequestPager<*>)?.refreshItem(position, itemId)
} }
fun updateBackdrop(item: BaseItem) {
viewModelScope.launchIO {
backdropService.submit(item)
}
}
} }
/** /**
@ -505,7 +530,7 @@ fun CollectionFolderGrid(
playEnabled: Boolean, playEnabled: Boolean,
defaultViewOptions: ViewOptions, defaultViewOptions: ViewOptions,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: CollectionFolderViewModel = hiltViewModel(), viewModel: CollectionFolderViewModel = hiltViewModel(key = itemId),
playlistViewModel: AddPlaylistViewModel = hiltViewModel(), playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
initialSortAndDirection: SortAndDirection? = null, initialSortAndDirection: SortAndDirection? = null,
showTitle: Boolean = true, showTitle: Boolean = true,
@ -578,8 +603,12 @@ fun CollectionFolderGrid(
viewOptions = viewOptions, viewOptions = viewOptions,
defaultViewOptions = defaultViewOptions, defaultViewOptions = defaultViewOptions,
onSaveViewOptions = { viewModel.saveViewOptions(it) }, onSaveViewOptions = { viewModel.saveViewOptions(it) },
onChangeBackdrop = viewModel::updateBackdrop,
playEnabled = playEnabled, playEnabled = playEnabled,
onClickPlay = { shuffle -> onClickPlay = { _, item ->
viewModel.navigationManager.navigateTo(Destination.Playback(item))
},
onClickPlayAll = { shuffle ->
itemId.toUUIDOrNull()?.let { itemId.toUUIDOrNull()?.let {
viewModel.navigationManager.navigateTo( viewModel.navigationManager.navigateTo(
Destination.PlaybackList( Destination.PlaybackList(
@ -678,17 +707,19 @@ fun CollectionFolderGridContent(
letterPosition: suspend (Char) -> Int, letterPosition: suspend (Char) -> Int,
sortOptions: List<ItemSortBy>, sortOptions: List<ItemSortBy>,
playEnabled: Boolean, playEnabled: Boolean,
onClickPlay: (shuffle: Boolean) -> Unit, getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>,
defaultViewOptions: ViewOptions,
onSaveViewOptions: (ViewOptions) -> Unit,
viewOptions: ViewOptions,
onClickPlayAll: (shuffle: Boolean) -> Unit,
onClickPlay: (Int, BaseItem) -> Unit,
onChangeBackdrop: (BaseItem) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
showTitle: Boolean = true, showTitle: Boolean = true,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null, positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
currentFilter: GetItemsFilter = GetItemsFilter(), currentFilter: GetItemsFilter = GetItemsFilter(),
filterOptions: List<ItemFilterBy<*>> = listOf(), filterOptions: List<ItemFilterBy<*>> = listOf(),
onFilterChange: (GetItemsFilter) -> Unit = {}, onFilterChange: (GetItemsFilter) -> Unit = {},
getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>,
defaultViewOptions: ViewOptions,
viewOptions: ViewOptions,
onSaveViewOptions: (ViewOptions) -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val title = item?.name ?: item?.data?.collectionType?.name ?: stringResource(R.string.collection) val title = item?.name ?: item?.data?.collectionType?.name ?: stringResource(R.string.collection)
@ -703,14 +734,13 @@ fun CollectionFolderGridContent(
var position by rememberInt(0) var position by rememberInt(0)
val focusedItem = pager.getOrNull(position) val focusedItem = pager.getOrNull(position)
if (viewOptions.showDetails) {
LaunchedEffect(focusedItem) {
focusedItem?.let(onChangeBackdrop)
}
}
Box(modifier = modifier) { Box(modifier = modifier) {
if (viewOptions.showDetails) {
DelayedDetailsBackdropImage(
item = focusedItem,
modifier = Modifier,
)
}
Column( Column(
verticalArrangement = Arrangement.spacedBy(0.dp), verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@ -786,12 +816,12 @@ fun CollectionFolderGridContent(
title = R.string.play, title = R.string.play,
resume = Duration.ZERO, resume = Duration.ZERO,
icon = Icons.Default.PlayArrow, icon = Icons.Default.PlayArrow,
onClick = { onClickPlay.invoke(false) }, onClick = { onClickPlayAll.invoke(false) },
) )
ExpandableFaButton( ExpandableFaButton(
title = R.string.shuffle, title = R.string.shuffle,
iconStringRes = R.string.fa_shuffle, iconStringRes = R.string.fa_shuffle,
onClick = { onClickPlay.invoke(true) }, onClick = { onClickPlayAll.invoke(true) },
) )
} }
} }
@ -813,6 +843,7 @@ fun CollectionFolderGridContent(
pager = pager, pager = pager,
onClickItem = onClickItem, onClickItem = onClickItem,
onLongClickItem = onLongClickItem, onLongClickItem = onLongClickItem,
onClickPlay = onClickPlay,
letterPosition = letterPosition, letterPosition = letterPosition,
gridFocusRequester = gridFocusRequester, gridFocusRequester = gridFocusRequester,
showJumpButtons = false, // TODO add preference showJumpButtons = false, // TODO add preference
@ -832,6 +863,7 @@ fun CollectionFolderGridContent(
imageContentScale = viewOptions.contentScale.scale, imageContentScale = viewOptions.contentScale.scale,
imageAspectRatio = viewOptions.aspectRatio.ratio, imageAspectRatio = viewOptions.aspectRatio.ratio,
imageType = viewOptions.imageType, imageType = viewOptions.imageType,
showTitle = viewOptions.showTitles,
modifier = mod, modifier = mod,
) )
}, },

View file

@ -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<String?>(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,
),
)
},
)
}

View file

@ -310,6 +310,9 @@ fun DialogPopup(
@Composable @Composable
fun ScrollableDialog( fun ScrollableDialog(
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
width: Dp = 600.dp,
maxHeight: Dp = 380.dp,
itemSpacing: Dp = 8.dp,
content: LazyListScope.() -> Unit, content: LazyListScope.() -> Unit,
) { ) {
val scrollAmount = 100f val scrollAmount = 100f
@ -331,12 +334,12 @@ fun ScrollableDialog(
LazyColumn( LazyColumn(
state = columnState, state = columnState,
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(itemSpacing),
content = content, content = content,
modifier = modifier =
Modifier Modifier
.width(600.dp) .width(width)
.heightIn(max = 380.dp) .heightIn(max = maxHeight)
.focusable() .focusable()
.background( .background(
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),

View file

@ -5,33 +5,43 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicSecureTextField import androidx.compose.foundation.text.BasicSecureTextField
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.KeyboardActionHandler import androidx.compose.foundation.text.input.KeyboardActionHandler
import androidx.compose.foundation.text.input.TextFieldDecorator import androidx.compose.foundation.text.input.TextFieldDecorator
import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.rememberTextFieldState 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.Icons
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TextFieldDefaults.Container
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction 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.compose.ui.unit.dp
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
import androidx.tv.material3.LocalContentColor 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.R
import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.google.protobuf.value
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @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 @PreviewTvSpec
@Composable @Composable
private fun EditTextBoxPreview() { private fun EditTextBoxPreview() {
@ -199,6 +363,10 @@ private fun EditTextBoxPreview() {
EditTextBox( EditTextBox(
state = rememberTextFieldState("string"), state = rememberTextFieldState("string"),
) )
EditTextBox(
value = "string",
onValueChange = {},
)
SearchEditTextBox( SearchEditTextBox(
state = rememberTextFieldState("search query"), state = rememberTextFieldState("search query"),
onSearchClick = { }, onSearchClick = { },

View file

@ -10,30 +10,43 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter 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.services.NavigationManager
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.SlimItemFields 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.CardGrid
import com.github.damontecres.wholphin.ui.detail.CardGridItem
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.tryRequestFocus 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.GetGenresRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.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.GetGenresRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import java.util.UUID import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@ -41,11 +54,12 @@ class GenreViewModel
@Inject @Inject
constructor( constructor(
private val api: ApiClient, private val api: ApiClient,
private val imageUrlService: ImageUrlService,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
) : ViewModel() { ) : ViewModel() {
private lateinit var itemId: UUID private lateinit var itemId: UUID
val loading = MutableLiveData<LoadingState>(LoadingState.Pending) val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
val genres = MutableLiveData<List<BaseItem?>>(listOf()) val genres = MutableLiveData<List<Genre>>(listOf())
fun init(itemId: UUID) { fun init(itemId: UUID) {
loading.value = LoadingState.Loading loading.value = LoadingState.Loading
@ -56,11 +70,69 @@ class GenreViewModel
parentId = itemId, parentId = itemId,
fields = SlimItemFields, 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) { withContext(Dispatchers.Main) {
genres.value = pager this@GenreViewModel.genres.value = genres
loading.value = LoadingState.Success loading.value = LoadingState.Success
} }
// val excludeItemIds = mutableSetOf<UUID>()
val genreToUrl = ConcurrentHashMap<UUID, String>()
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 @Composable
fun GenreCardGrid( fun GenreCardGrid(
itemId: UUID, itemId: UUID,
@ -117,6 +199,7 @@ fun GenreCardGrid(
) )
}, },
onLongClickItem = { _, _ -> }, onLongClickItem = { _, _ -> },
onClickPlay = { _, _ -> },
letterPosition = { viewModel.positionOfLetter(it) }, letterPosition = { viewModel.positionOfLetter(it) },
gridFocusRequester = gridFocusRequester, gridFocusRequester = gridFocusRequester,
showJumpButtons = false, showJumpButtons = false,
@ -125,13 +208,13 @@ fun GenreCardGrid(
initialPosition = 0, initialPosition = 0,
positionCallback = { columns, position -> positionCallback = { columns, position ->
}, },
cardContent = { item: BaseItem?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier -> columns = 4,
GridCard( cardContent = { item: Genre?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier ->
item = item, GenreCard(
genre = item,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
modifier = mod, modifier = mod,
imageAspectRatio = 1f,
) )
}, },
) )

View file

@ -124,6 +124,7 @@ fun ItemGrid(
viewModel.navigateTo(Destination.Playback(item.id, 0)) viewModel.navigateTo(Destination.Playback(item.id, 0))
}, },
onLongClickItem = { index: Int, item: BaseItem -> }, onLongClickItem = { index: Int, item: BaseItem -> },
onClickPlay = { _, item -> viewModel.navigateTo(Destination.Playback(item)) },
letterPosition = { c: Char -> 0 }, letterPosition = { c: Char -> 0 },
gridFocusRequester = focusRequester, gridFocusRequester = focusRequester,
showJumpButtons = false, showJumpButtons = false,

View file

@ -1,29 +1,32 @@
package com.github.damontecres.wholphin.ui.components package com.github.damontecres.wholphin.ui.components
import android.content.Context
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.tv.material3.MaterialTheme 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.roundMinutes
import com.github.damontecres.wholphin.ui.timeRemaining 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.api.BaseItemDto
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import java.time.LocalDateTime
@Composable @Composable
fun MovieQuickDetails( fun MovieQuickDetails(
dto: BaseItemDto?, dto: BaseItemDto?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current
val now = LocalClock.current.now
val details = val details =
remember(dto) { remember(dto, now) {
buildList { buildList {
dto?.productionYear?.let { add(it.toString()) } dto?.productionYear?.let { add(it.toString()) }
dto?.runTimeTicks?.ticks?.roundMinutes?.let { addRuntimeDetails(context, now, dto)
add(it.toString())
}
dto?.timeRemaining?.roundMinutes?.let {
add("$it left")
}
dto?.officialRating?.let(::add) dto?.officialRating?.let(::add)
} }
} }
@ -36,3 +39,21 @@ fun MovieQuickDetails(
modifier = modifier, modifier = modifier,
) )
} }
fun MutableList<String>.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))
}
}

View file

@ -78,39 +78,37 @@ fun ExpandablePlayButtons(
if (resumePosition > Duration.ZERO) { if (resumePosition > Duration.ZERO) {
item("play") { item("play") {
ExpandablePlayButton( ExpandablePlayButton(
R.string.resume, title = R.string.resume,
resumePosition, resume = resumePosition,
Icons.Default.PlayArrow, icon = Icons.Default.PlayArrow,
playOnClick, onClick = playOnClick,
Modifier modifier =
.onFocusChanged(buttonOnFocusChanged) Modifier
.focusRequester(firstFocus) .onFocusChanged(buttonOnFocusChanged)
.animateItem(), .focusRequester(firstFocus),
) )
} }
item("restart") { item("restart") {
ExpandablePlayButton( ExpandablePlayButton(
R.string.restart, title = R.string.restart,
Duration.ZERO, resume = Duration.ZERO,
Icons.Default.Refresh, icon = Icons.Default.Refresh,
playOnClick, onClick = playOnClick,
Modifier modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
.onFocusChanged(buttonOnFocusChanged)
.animateItem(),
mirrorIcon = true, mirrorIcon = true,
) )
} }
} else { } else {
item("play") { item("play") {
ExpandablePlayButton( ExpandablePlayButton(
R.string.play, title = R.string.play,
Duration.ZERO, resume = Duration.ZERO,
Icons.Default.PlayArrow, icon = Icons.Default.PlayArrow,
playOnClick, onClick = playOnClick,
Modifier modifier =
.onFocusChanged(buttonOnFocusChanged) Modifier
.focusRequester(firstFocus) .onFocusChanged(buttonOnFocusChanged)
.animateItem(), .focusRequester(firstFocus),
) )
} }
} }
@ -121,10 +119,7 @@ fun ExpandablePlayButtons(
title = if (watched) R.string.mark_unwatched else R.string.mark_watched, 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, iconStringRes = if (watched) R.string.fa_eye else R.string.fa_eye_slash,
onClick = watchOnClick, onClick = watchOnClick,
modifier = modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
Modifier
.onFocusChanged(buttonOnFocusChanged)
.animateItem(),
) )
} }
@ -135,23 +130,18 @@ fun ExpandablePlayButtons(
iconStringRes = R.string.fa_heart, iconStringRes = R.string.fa_heart,
onClick = favoriteOnClick, onClick = favoriteOnClick,
iconColor = if (favorite) Color.Red else Color.Unspecified, iconColor = if (favorite) Color.Red else Color.Unspecified,
modifier = modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
Modifier
.onFocusChanged(buttonOnFocusChanged)
.animateItem(),
) )
} }
// More button // More button
item("more") { item("more") {
ExpandablePlayButton( ExpandablePlayButton(
R.string.more, title = R.string.more,
Duration.ZERO, resume = Duration.ZERO,
Icons.Default.MoreVert, icon = Icons.Default.MoreVert,
{ moreOnClick.invoke() }, onClick = { moreOnClick.invoke() },
Modifier modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
.onFocusChanged(buttonOnFocusChanged)
.animateItem(),
) )
} }
} }

View file

@ -20,6 +20,7 @@ import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences 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.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
@ -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.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.HomePageContent import com.github.damontecres.wholphin.ui.main.HomePageContent
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
@ -44,6 +46,7 @@ abstract class RecommendedViewModel(
val context: Context, val context: Context,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
val favoriteWatchManager: FavoriteWatchManager, val favoriteWatchManager: FavoriteWatchManager,
private val backdropService: BackdropService,
) : ViewModel() { ) : ViewModel() {
abstract fun init() abstract fun init()
@ -85,6 +88,12 @@ abstract class RecommendedViewModel(
} }
} }
fun updateBackdrop(item: BaseItem) {
viewModelScope.launchIO {
backdropService.submit(item)
}
}
abstract fun update( abstract fun update(
@StringRes title: Int, @StringRes title: Int,
row: HomeRowLoadingState, row: HomeRowLoadingState,
@ -146,8 +155,12 @@ fun RecommendedContent(
onLongClickItem = { position, item -> onLongClickItem = { position, item ->
moreDialog.makePresent(RowColumnItem(position, item)) moreDialog.makePresent(RowColumnItem(position, item))
}, },
onClickPlay = { _, item ->
viewModel.navigationManager.navigateTo(Destination.Playback(item))
},
onFocusPosition = onFocusPosition, onFocusPosition = onFocusPosition,
showClock = preferences.appPreferences.interfacePreferences.showClock, showClock = preferences.appPreferences.interfacePreferences.showClock,
onUpdateBackdrop = viewModel::updateBackdrop,
modifier = modifier, modifier = modifier,
) )
} }

View file

@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
@ -56,7 +57,8 @@ class RecommendedMovieViewModel
@Assisted val parentId: UUID, @Assisted val parentId: UUID,
navigationManager: NavigationManager, navigationManager: NavigationManager,
favoriteWatchManager: FavoriteWatchManager, favoriteWatchManager: FavoriteWatchManager,
) : RecommendedViewModel(context, navigationManager, favoriteWatchManager) { backdropService: BackdropService,
) : RecommendedViewModel(context, navigationManager, favoriteWatchManager, backdropService) {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(parentId: UUID): RecommendedMovieViewModel fun create(parentId: UUID): RecommendedMovieViewModel

View file

@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
@ -59,7 +60,8 @@ class RecommendedTvShowViewModel
@Assisted val parentId: UUID, @Assisted val parentId: UUID,
navigationManager: NavigationManager, navigationManager: NavigationManager,
favoriteWatchManager: FavoriteWatchManager, favoriteWatchManager: FavoriteWatchManager,
) : RecommendedViewModel(context, navigationManager, favoriteWatchManager) { backdropService: BackdropService,
) : RecommendedViewModel(context, navigationManager, favoriteWatchManager, backdropService) {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(parentId: UUID): RecommendedTvShowViewModel fun create(parentId: UUID): RecommendedTvShowViewModel

View file

@ -3,6 +3,8 @@ package com.github.damontecres.wholphin.ui.components
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier 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.compose.ui.text.style.TextOverflow
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text 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.roundMinutes
import com.github.damontecres.wholphin.ui.seasonEpisode import com.github.damontecres.wholphin.ui.seasonEpisode
import com.github.damontecres.wholphin.ui.seriesProductionYears 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.api.BaseItemDto
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
@ -23,6 +25,7 @@ fun SeriesName(
text = seriesName ?: "", text = seriesName ?: "",
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = modifier, modifier = modifier,
@ -55,17 +58,14 @@ fun EpisodeQuickDetails(
dto: BaseItemDto?, dto: BaseItemDto?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current
val now = LocalClock.current.now
val details = val details =
remember(dto) { remember(dto, now) {
buildList { buildList {
dto?.seasonEpisode?.let(::add) dto?.seasonEpisode?.let(::add)
dto?.premiereDate?.let { add(formatDateTime(it)) } dto?.premiereDate?.let { add(formatDateTime(it)) }
val duration = dto?.runTimeTicks?.ticks addRuntimeDetails(context, now, dto)
duration
?.roundMinutes
?.toString()
?.let(::add)
dto?.timeRemaining?.roundMinutes?.let { add("$it left") }
dto?.officialRating?.let(::add) dto?.officialRating?.let(::add)
} }
} }
@ -87,11 +87,9 @@ fun SeriesQuickDetails(
remember(dto) { remember(dto) {
buildList { buildList {
dto?.seriesProductionYears?.let(::add) dto?.seriesProductionYears?.let(::add)
val duration = dto?.runTimeTicks?.ticks dto?.runTimeTicks?.ticks?.roundMinutes?.let {
duration add(it.toString())
?.roundMinutes }
?.toString()
?.let(::add)
dto?.officialRating?.let(::add) dto?.officialRating?.let(::add)
} }
} }

View file

@ -3,35 +3,21 @@ package com.github.damontecres.wholphin.ui.components
import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.ui.TimeFormatter import com.github.damontecres.wholphin.ui.util.LocalClock
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import java.time.LocalTime
@Composable @Composable
fun BoxScope.TimeDisplay(modifier: Modifier = Modifier) { fun BoxScope.TimeDisplay(modifier: Modifier = Modifier) {
var now by remember { mutableStateOf(LocalTime.now()) }
LaunchedEffect(Unit) {
while (isActive) {
now = LocalTime.now()
delay(1000L)
}
}
Text( Text(
text = TimeFormatter.format(now), text = LocalClock.current.timeString,
fontSize = 18.sp, fontSize = 18.sp,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyLarge,
modifier = modifier =
modifier modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)

View file

@ -9,7 +9,11 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -23,30 +27,41 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.ProvideTextStyle import androidx.tv.material3.ProvideTextStyle
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.ChosenStreams
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.AppThemeColors 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.FontAwesome
import com.github.damontecres.wholphin.ui.PreviewTvSpec 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.audioStreamCount
import com.github.damontecres.wholphin.ui.playback.embeddedSubtitleCount import com.github.damontecres.wholphin.ui.playback.embeddedSubtitleCount
import com.github.damontecres.wholphin.ui.playback.externalSubtitlesCount import com.github.damontecres.wholphin.ui.playback.externalSubtitlesCount
import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.util.languageName import com.github.damontecres.wholphin.util.languageName
import com.github.damontecres.wholphin.util.profile.Codec import com.github.damontecres.wholphin.util.profile.Codec
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.VideoRange import org.jellyfin.sdk.model.api.VideoRange
import org.jellyfin.sdk.model.api.VideoRangeType import org.jellyfin.sdk.model.api.VideoRangeType
@Composable @Composable
@NonRestartableComposable
fun VideoStreamDetails( fun VideoStreamDetails(
preferences: UserPreferences, chosenStreams: ChosenStreams?,
dto: BaseItemDto, modifier: Modifier = Modifier,
itemPlayback: ItemPlayback?, ) = VideoStreamDetails(
chosenStreams?.source,
chosenStreams?.videoStream,
chosenStreams?.audioStream,
chosenStreams?.subtitleStream,
modifier,
)
@Composable
fun VideoStreamDetails(
source: MediaSourceInfo?,
videoStream: MediaStream?,
audioStream: MediaStream?,
subtitleStream: MediaStream?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@ -54,17 +69,6 @@ fun VideoStreamDetails(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier, modifier = modifier,
) { ) {
val source = remember(dto, itemPlayback) { chooseSource(dto, itemPlayback) }
val videoStream =
remember(dto, itemPlayback) {
chooseStream(
dto,
itemPlayback,
MediaStreamType.VIDEO,
preferences,
)
}
val video = val video =
remember(videoStream) { remember(videoStream) {
videoStream videoStream
@ -81,7 +85,7 @@ fun VideoStreamDetails(
} else { } else {
null null
} }
val range = formatVideoRange(context, it.videoRange, it.videoRangeType) val range = formatVideoRange(context, it.videoRange, it.videoRangeType, it.videoDoViTitle)
listOfNotNull( listOfNotNull(
resName.concatWithSpace(range), resName.concatWithSpace(range),
it.codec?.uppercase(), it.codec?.uppercase(),
@ -92,25 +96,18 @@ fun VideoStreamDetails(
StreamLabel(it) StreamLabel(it)
} }
val audioStream =
remember(dto, itemPlayback) {
chooseStream(
dto,
itemPlayback,
MediaStreamType.AUDIO,
preferences,
)
}
val audioCount = remember(source) { source?.audioStreamCount ?: 0 } val audioCount = remember(source) { source?.audioStreamCount ?: 0 }
val audio = val audio =
if (audioCount == 0 || audioStream == null) { remember(audioCount, audioStream) {
stringResource(R.string.none) if (audioCount == 0 || audioStream == null) {
} else { context.getString(R.string.none)
listOfNotNull( } else {
languageName(audioStream.language), listOfNotNull(
formatAudioCodec(context, audioStream.codec, audioStream.profile), languageName(audioStream.language),
audioStream.channelLayout, formatAudioCodec(context, audioStream.codec, audioStream.profile),
).joinToString(" ") audioStream.channelLayout,
).joinToString(" ")
}
} }
StreamLabel( StreamLabel(
text = audio, text = audio,
@ -119,30 +116,26 @@ fun VideoStreamDetails(
modifier = Modifier.widthIn(max = 200.dp), modifier = Modifier.widthIn(max = 200.dp),
) )
val subtitleStream =
remember(dto, itemPlayback) {
chooseStream(
dto,
itemPlayback,
MediaStreamType.SUBTITLE,
preferences,
)
}
val subtitleCount = val subtitleCount =
remember(source) { remember(source) {
(source?.embeddedSubtitleCount ?: 0) + (source?.externalSubtitlesCount ?: 0) (source?.embeddedSubtitleCount ?: 0) + (source?.externalSubtitlesCount ?: 0)
} }
var disabled by remember { mutableStateOf(false) }
val subtitle = val subtitle =
if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) { remember(subtitleCount, subtitleStream) {
stringResource(R.string.disabled) if (subtitleCount > 0 && subtitleStream == null) {
} else if (subtitleCount == 0 || subtitleStream == null) { disabled = true
null context.getString(R.string.disabled)
} else { } else if (subtitleCount == 0 || subtitleStream == null) {
listOfNotNull( null
languageName(subtitleStream.language), } else {
"SDH".takeIf { subtitleStream.isHearingImpaired }, disabled = false
formatSubtitleCodec(subtitleStream.codec), listOfNotNull(
).joinToString(" ") languageName(subtitleStream.language),
"SDH".takeIf { subtitleStream.isHearingImpaired },
formatSubtitleCodec(subtitleStream.codec),
).joinToString(" ")
}
} }
subtitle?.let { subtitle?.let {
StreamLabel( StreamLabel(
@ -150,6 +143,7 @@ fun VideoStreamDetails(
count = subtitleCount, count = subtitleCount,
icon = R.string.fa_closed_captioning, icon = R.string.fa_closed_captioning,
modifier = Modifier.widthIn(max = 160.dp), modifier = Modifier.widthIn(max = 160.dp),
disabled = disabled,
) )
} }
} }
@ -190,6 +184,7 @@ fun StreamLabel(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@StringRes icon: Int? = null, @StringRes icon: Int? = null,
count: Int = 0, count: Int = 0,
disabled: Boolean = false,
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@ -222,9 +217,10 @@ fun StreamLabel(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier, modifier = Modifier,
) )
if (count > 1) { val countToUse = if (disabled) count else count - 1
if (countToUse > 0) {
Text( Text(
text = "(+${count - 1})", text = "(+$countToUse)",
maxLines = 1, maxLines = 1,
modifier = Modifier, modifier = Modifier,
) )
@ -248,6 +244,10 @@ private fun StreamLabelPreview() {
StreamLabel("HDR") StreamLabel("HDR")
StreamLabel("H264") StreamLabel("H264")
StreamLabel("AC3 5.1", icon = R.string.fa_volume_high, count = 2) 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, context: Context,
videoRange: VideoRange?, videoRange: VideoRange?,
type: VideoRangeType?, type: VideoRangeType?,
doviTitle: String?,
): String? = ): String? =
when (videoRange) { when (videoRange) {
VideoRange.UNKNOWN, VideoRange.UNKNOWN,
@ -265,23 +266,27 @@ fun formatVideoRange(
} }
VideoRange.HDR -> { VideoRange.HDR -> {
when (type) { if (doviTitle.isNotNullOrBlank()) {
VideoRangeType.UNKNOWN, context.getString(R.string.dolby_vision)
VideoRangeType.SDR, } else {
null, when (type) {
-> null 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,
VideoRangeType.DOVI_WITH_HDR10, VideoRangeType.DOVI_WITH_HDR10,
VideoRangeType.DOVI_WITH_HLG, VideoRangeType.DOVI_WITH_HLG,
VideoRangeType.DOVI_WITH_SDR, VideoRangeType.DOVI_WITH_SDR,
-> context.getString(R.string.dolby_vision) -> context.getString(R.string.dolby_vision)
}
} }
} }
} }

View file

@ -113,6 +113,7 @@ data class ViewOptions(
val aspectRatio: AspectRatio = AspectRatio.TALL, val aspectRatio: AspectRatio = AspectRatio.TALL,
val showDetails: Boolean = false, val showDetails: Boolean = false,
val imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY, val imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
val showTitles: Boolean = true,
) { ) {
companion object { companion object {
val ViewOptionsColumns = val ViewOptionsColumns =
@ -165,6 +166,13 @@ data class ViewOptions(
getter = { it.showDetails }, getter = { it.showDetails },
setter = { vo, value -> vo.copy(showDetails = value) }, setter = { vo, value -> vo.copy(showDetails = value) },
) )
val ViewOptionsShowTitles =
AppSwitchPreference<ViewOptions>(
title = R.string.show_titles,
defaultValue = true,
getter = { it.showTitles },
setter = { vo, value -> vo.copy(showTitles = value) },
)
val ViewOptionsImageType = val ViewOptionsImageType =
AppChoicePreference<ViewOptions, ViewOptionImageType>( AppChoicePreference<ViewOptions, ViewOptionImageType>(
@ -194,6 +202,7 @@ data class ViewOptions(
ViewOptionsImageType, ViewOptionsImageType,
ViewOptionsAspectRatio, ViewOptionsAspectRatio,
ViewOptionsDetailHeader, ViewOptionsDetailHeader,
ViewOptionsShowTitles,
ViewOptionsColumns, ViewOptionsColumns,
ViewOptionsSpacing, ViewOptionsSpacing,
ViewOptionsContentScale, ViewOptionsContentScale,

View file

@ -1,28 +1,35 @@
package com.github.damontecres.wholphin.ui.data package com.github.damontecres.wholphin.ui.data
import android.content.Context
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.ProvideTextStyle
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.byteRateSuffixes import com.github.damontecres.wholphin.ui.byteRateSuffixes
import com.github.damontecres.wholphin.ui.components.ScrollableDialog 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.formatBytes
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.util.languageName 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.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType 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 import java.util.Locale
data class ItemDetailsDialogInfo( data class ItemDetailsDialogInfo(
@ -37,129 +44,338 @@ fun ItemDetailsDialog(
info: ItemDetailsDialogInfo, info: ItemDetailsDialogInfo,
showFilePath: Boolean, showFilePath: Boolean,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
) = ScrollableDialog(
onDismissRequest = onDismissRequest,
) { ) {
item { val context = LocalContext.current
Text( // Extract stringResource calls outside of ScrollableDialog's non-composable lambda
text = info.title, val pathLabel = stringResource(R.string.path)
style = MaterialTheme.typography.titleLarge, val fileSizeLabel = stringResource(R.string.file_size)
) val videoLabel = stringResource(R.string.video)
} val audioLabel = stringResource(R.string.audio)
if (info.genres.isNotEmpty()) { 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 { item {
Text( Text(
text = info.genres.joinToString(", "), text = info.title,
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleLarge,
) )
} }
} if (info.genres.isNotEmpty()) {
if (info.overview.isNotNullOrBlank()) { item {
item { Text(
Text( text = info.genres.joinToString(", "),
text = info.overview, style = MaterialTheme.typography.titleSmall,
style = MaterialTheme.typography.bodyLarge, )
) }
} }
} if (info.overview.isNotNullOrBlank()) {
if (info.files.isNotEmpty()) { item {
item { Text(
Spacer(Modifier.height(12.dp)) text = info.overview,
style = MaterialTheme.typography.bodyLarge,
)
}
} }
}
items(info.files) { file -> // Show detailed media information for the selected source (first one if multiple)
ProvideTextStyle(MaterialTheme.typography.bodyMedium) { info.files.forEachIndexed { index, source ->
MediaSourceInfo(file, showFilePath, Modifier.padding(top = 12.dp)) 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 @Composable
fun MediaSourceInfo( private fun MediaInfoSection(
source: MediaSourceInfo, title: String,
showFilePath: Boolean, items: List<Pair<String, String>>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column( Column(
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = modifier, modifier = modifier.padding(vertical = 4.dp),
) { ) {
Text( Text(
text = stringResource(R.string.name) + ": ${source.name}", text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
) )
Text( items.forEach { (label, value) ->
text = "ID: ${source.id}", Row(
) modifier = Modifier.padding(start = 12.dp),
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)
}
Text( 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(
text = stringResource(R.string.audio) + " ${index + 1}: " + data.joinToString(" - "), text = value,
) style = MaterialTheme.typography.bodyMedium,
}
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(" - "),
) )
} }
} }
} }
} }
private fun buildVideoStreamInfo(
context: Context,
stream: MediaStream,
): List<Pair<String, String>> =
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<Pair<String, String>> =
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<Pair<String, String>> =
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)

View file

@ -22,6 +22,7 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.layout.LazyLayoutCacheWindow import androidx.compose.foundation.lazy.layout.LazyLayoutCacheWindow
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -33,11 +34,14 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key 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.text.style.TextAlign
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.Button
import androidx.tv.material3.ButtonDefaults
import androidx.tv.material3.LocalContentColor import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text 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.AppColors
import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.cards.GridCard 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.ifElse
import com.github.damontecres.wholphin.ui.playback.isBackwardButton import com.github.damontecres.wholphin.ui.playback.isBackwardButton
import com.github.damontecres.wholphin.ui.playback.isForwardButton import com.github.damontecres.wholphin.ui.playback.isForwardButton
@ -69,15 +73,23 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
import java.util.UUID
private const val DEBUG = false private const val DEBUG = false
interface CardGridItem {
val id: UUID
val playable: Boolean
val sortName: String
}
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun CardGrid( fun <T : CardGridItem> CardGrid(
pager: List<BaseItem?>, pager: List<T?>,
onClickItem: (Int, BaseItem) -> Unit, onClickItem: (Int, T) -> Unit,
onLongClickItem: (Int, BaseItem) -> Unit, onLongClickItem: (Int, T) -> Unit,
onClickPlay: (Int, T) -> Unit,
letterPosition: suspend (Char) -> Int, letterPosition: suspend (Char) -> Int,
gridFocusRequester: FocusRequester, gridFocusRequester: FocusRequester,
showJumpButtons: Boolean, showJumpButtons: Boolean,
@ -86,13 +98,13 @@ fun CardGrid(
initialPosition: Int = 0, initialPosition: Int = 0,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null, positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
cardContent: @Composable ( cardContent: @Composable (
item: BaseItem?, item: T?,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
mod: Modifier, mod: Modifier,
) -> Unit = { item, onClick, onLongClick, mod -> ) -> Unit = { item, onClick, onLongClick, mod ->
GridCard( GridCard(
item = item, item = item as BaseItem?,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
imageContentScale = ContentScale.FillBounds, imageContentScale = ContentScale.FillBounds,
@ -213,7 +225,11 @@ fun CardGrid(
jumpToTop() jumpToTop()
return@onKeyEvent true return@onKeyEvent true
} else if (isPlayKeyUp(it)) { } 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 return@onKeyEvent true
} else if (useJumpRemoteButtons && isForwardButton(it)) { } else if (useJumpRemoteButtons && isForwardButton(it)) {
jump(jump1) jump(jump1)
@ -341,7 +357,6 @@ fun CardGrid(
remember(focusedIndex) { remember(focusedIndex) {
pager pager
.getOrNull(focusedIndex) .getOrNull(focusedIndex)
?.data
?.sortName ?.sortName
?.first() ?.first()
?.uppercaseChar() ?.uppercaseChar()
@ -360,7 +375,11 @@ fun CardGrid(
AlphabetButtons( AlphabetButtons(
letters = letters, letters = letters,
currentLetter = currentLetter, 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 -> letterClicked = { letter ->
scope.launch(ExceptionHandler()) { scope.launch(ExceptionHandler()) {
val jumpPosition = val jumpPosition =
@ -369,6 +388,7 @@ fun CardGrid(
} }
Timber.d("Alphabet jump to $jumpPosition") Timber.d("Alphabet jump to $jumpPosition")
if (jumpPosition >= 0) { if (jumpPosition >= 0) {
pager.getOrNull(jumpPosition)
gridState.scrollToItem(jumpPosition) gridState.scrollToItem(jumpPosition)
focusOn(jumpPosition) focusOn(jumpPosition)
alphabetFocus = true alphabetFocus = true
@ -422,7 +442,6 @@ fun AlphabetButtons(
letterClicked: (Char) -> Unit, letterClicked: (Char) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val listState = rememberLazyListState() val listState = rememberLazyListState()
@ -434,51 +453,97 @@ fun AlphabetButtons(
listState.layoutInfo.visibleItemsInfo listState.layoutInfo.visibleItemsInfo
.lastOrNull() .lastOrNull()
?.index ?: -1 ?.index ?: -1
if (index < firstVisibleItemIndex || index > lastVisibleItemIndex) { if (index !in firstVisibleItemIndex..lastVisibleItemIndex) {
listState.animateScrollToItem(index) listState.animateScrollToItem(index)
} }
} }
} }
// Focus & interaction states for each letter button
val focusRequesters = remember { List(letters.length) { FocusRequester() } } 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( LazyColumn(
contentPadding = PaddingValues(4.dp), contentPadding = PaddingValues(vertical = 1.1.dp, horizontal = 2.dp),
verticalArrangement = Arrangement.spacedBy(1.1.dp),
state = listState, state = listState,
modifier = modifier =
modifier.focusProperties { modifier
onEnter = { .onFocusChanged { focusState ->
focusRequesters[index.coerceIn(0, letters.length - 1)].tryRequestFocus() alphabetPickerFocused = focusState.hasFocus
} }.focusProperties {
}, onEnter = {
focusRequesters[index.coerceIn(0, letters.length - 1)].tryRequestFocus()
}
},
) { ) {
items( items(
letters.length, letters.length,
key = { letters[it] }, key = { letters[it] },
) { index -> ) { index ->
val interactionSource = remember { MutableInteractionSource() } val interactionSource = interactionSources[index]
val focused by interactionSource.collectIsFocusedAsState() 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 =
Modifier Modifier
.size(24.dp) .size(14.dp)
.focusRequester(focusRequesters[index]), .clip(CircleShape)
contentPadding = PaddingValues(2.dp), .alpha(itemAlpha),
interactionSource = interactionSource,
onClick = {
letterClicked.invoke(letters[index])
},
) { ) {
val color = Button(
if (!focused && letters[index] == currentLetter) { modifier =
MaterialTheme.colorScheme.tertiary Modifier
} else { .size(14.dp)
LocalContentColor.current .focusRequester(focusRequesters[index]),
} contentPadding = PaddingValues(0.dp), // No padding to maximize text space
Text( interactionSource = interactionSource,
text = letters[index].toString(), onClick = {
color = color, letterClicked.invoke(letters[index])
textAlign = TextAlign.Center, },
modifier = Modifier.fillMaxWidth(), 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,
)
}
} }
} }
} }

View file

@ -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.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
@ -59,6 +60,7 @@ class LiveTvCollectionViewModel
val serverRepository: ServerRepository, val serverRepository: ServerRepository,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
val rememberTabManager: RememberTabManager, val rememberTabManager: RememberTabManager,
val backdropService: BackdropService,
) : ViewModel(), ) : ViewModel(),
RememberTabManager by rememberTabManager { RememberTabManager by rememberTabManager {
val recordingFolders = MutableLiveData<List<TabId>>() val recordingFolders = MutableLiveData<List<TabId>>()
@ -106,6 +108,7 @@ fun CollectionFolderLiveTv(
LaunchedEffect(selectedTabIndex) { LaunchedEffect(selectedTabIndex) {
logTab("livetv", selectedTabIndex) logTab("livetv", selectedTabIndex)
viewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex) viewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
viewModel.backdropService.clearBackdrop()
} }
val onClickItem = { position: Int, item: BaseItem -> val onClickItem = { position: Int, item: BaseItem ->
viewModel.navigationManager.navigateTo(item.destination()) viewModel.navigationManager.navigateTo(item.destination())

View file

@ -65,6 +65,7 @@ fun CollectionFolderMovie(
LaunchedEffect(selectedTabIndex) { LaunchedEffect(selectedTabIndex) {
logTab("movie", selectedTabIndex) logTab("movie", selectedTabIndex)
preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex) preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
preferencesViewModel.backdropService.clearBackdrop()
} }
var showHeader by rememberSaveable { mutableStateOf(true) } var showHeader by rememberSaveable { mutableStateOf(true) }

View file

@ -64,6 +64,7 @@ fun CollectionFolderTv(
LaunchedEffect(selectedTabIndex) { LaunchedEffect(selectedTabIndex) {
logTab("tv", selectedTabIndex) logTab("tv", selectedTabIndex)
preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex) preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
preferencesViewModel.backdropService.clearBackdrop()
} }
val onClickItem = { item: BaseItem -> val onClickItem = { item: BaseItem ->

View file

@ -36,6 +36,7 @@ import com.github.damontecres.wholphin.data.ItemPlaybackDao
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.RefreshRateService
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
@ -58,6 +59,9 @@ class DebugViewModel
constructor( constructor(
val serverRepository: ServerRepository, val serverRepository: ServerRepository,
val itemPlaybackDao: ItemPlaybackDao, val itemPlaybackDao: ItemPlaybackDao,
val refreshRateService: RefreshRateService,
val clientInfo: ClientInfo,
val deviceInfo: DeviceInfo,
) : ViewModel() { ) : ViewModel() {
val itemPlaybacks = MutableLiveData<List<ItemPlayback>>(listOf()) val itemPlaybacks = MutableLiveData<List<ItemPlayback>>(listOf())
val logcat = MutableLiveData<List<LogcatLine>>(listOf()) val logcat = MutableLiveData<List<LogcatLine>>(listOf())
@ -220,6 +224,7 @@ fun DebugPage(
listOf( listOf(
"Version Name: ${pkgInfo.versionName}", "Version Name: ${pkgInfo.versionName}",
"Version Code: ${pkgInfo.versionCodeLong}", "Version Code: ${pkgInfo.versionCodeLong}",
"ClientInfo: ${viewModel.clientInfo}",
"Build type: ${BuildConfig.BUILD_TYPE}", "Build type: ${BuildConfig.BUILD_TYPE}",
"Debug enabled: ${BuildConfig.DEBUG}", "Debug enabled: ${BuildConfig.DEBUG}",
"ABIs: ${Build.SUPPORTED_ABIS.toList()}", "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 { item {
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),

View file

@ -4,6 +4,7 @@ import android.content.Context
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.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.PlayArrow
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -43,6 +44,7 @@ data class MoreDialogActions(
* @param navigateTo a function to trigger a navigation * @param navigateTo a function to trigger a navigation
* @param onChooseVersion callback to pick a version of the item * @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 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( fun buildMoreDialogItems(
context: Context, context: Context,
@ -54,6 +56,7 @@ fun buildMoreDialogItems(
actions: MoreDialogActions, actions: MoreDialogActions,
onChooseVersion: () -> Unit, onChooseVersion: () -> Unit,
onChooseTracks: (MediaStreamType) -> Unit, onChooseTracks: (MediaStreamType) -> Unit,
onShowOverview: () -> Unit,
): List<DialogItem> = ): List<DialogItem> =
buildList { buildList {
add( 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( add(
DialogItem( DialogItem(
context.getString(R.string.play_with_transcoding), context.getString(R.string.play_with_transcoding),

View file

@ -82,6 +82,7 @@ fun FavoritesPage(
NavDrawerItem.Favorites.id, NavDrawerItem.Favorites.id,
selectedTabIndex, selectedTabIndex,
) )
preferencesViewModel.backdropService.clearBackdrop()
} }
var showHeader by rememberSaveable { mutableStateOf(true) } var showHeader by rememberSaveable { mutableStateOf(true) }

View file

@ -50,10 +50,11 @@ import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem 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.services.NavigationManager
import com.github.damontecres.wholphin.ui.DefaultItemFields 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.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.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.DialogPopup
@ -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.components.OverviewText
import com.github.damontecres.wholphin.ui.enableMarquee import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.ui.tryRequestFocus 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.ApiRequestPager
import com.github.damontecres.wholphin.util.GetPlaylistItemsRequestHandler import com.github.damontecres.wholphin.util.GetPlaylistItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler
@ -88,6 +91,7 @@ class PlaylistViewModel
constructor( constructor(
api: ApiClient, api: ApiClient,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
private val backdropService: BackdropService,
) : ItemViewModel(api) { ) : ItemViewModel(api) {
val loading = MutableLiveData<LoadingState>(LoadingState.Pending) val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
val items = MutableLiveData<List<BaseItem?>>(listOf()) val items = MutableLiveData<List<BaseItem?>>(listOf())
@ -111,6 +115,12 @@ class PlaylistViewModel
} }
} }
} }
fun updateBackdrop(item: BaseItem) {
viewModelScope.launchIO {
backdropService.submit(item)
}
}
} }
@Composable @Composable
@ -146,6 +156,7 @@ fun PlaylistDetails(
playlist = it, playlist = it,
items = items, items = items,
focusRequester = focusRequester, focusRequester = focusRequester,
onChangeBackdrop = viewModel::updateBackdrop,
onClickIndex = { index, _ -> onClickIndex = { index, _ ->
viewModel.navigationManager.navigateTo( viewModel.navigationManager.navigateTo(
Destination.PlaybackList( Destination.PlaybackList(
@ -212,6 +223,7 @@ fun PlaylistDetailsContent(
onClickIndex: (Int, BaseItem) -> Unit, onClickIndex: (Int, BaseItem) -> Unit,
onLongClickIndex: (Int, BaseItem) -> Unit, onLongClickIndex: (Int, BaseItem) -> Unit,
onClickPlay: (shuffle: Boolean) -> Unit, onClickPlay: (shuffle: Boolean) -> Unit,
onChangeBackdrop: (BaseItem) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
focusRequester: FocusRequester = remember { FocusRequester() }, focusRequester: FocusRequester = remember { FocusRequester() },
) { ) {
@ -219,13 +231,15 @@ fun PlaylistDetailsContent(
var focusedIndex by remember { mutableIntStateOf(savedIndex) } var focusedIndex by remember { mutableIntStateOf(savedIndex) }
val focus = remember { FocusRequester() } val focus = remember { FocusRequester() }
val focusedItem = items.getOrNull(focusedIndex) val focusedItem = items.getOrNull(focusedIndex)
LaunchedEffect(focusedItem) {
focusedItem?.let(onChangeBackdrop)
}
val playButtonFocusRequester = remember { FocusRequester() } val playButtonFocusRequester = remember { FocusRequester() }
Box( Box(
modifier = modifier, modifier = modifier,
) { ) {
DelayedDetailsBackdropImage(focusedItem)
Column( Column(
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier =
@ -395,10 +409,22 @@ fun PlaylistItem(
) )
}, },
trailingContent = { trailingContent = {
item?.data?.runTimeTicks?.ticks?.roundMinutes?.let { item?.data?.runTimeTicks?.ticks?.roundMinutes?.let { duration ->
Text( val now = LocalClock.current.now
text = it.toString(), 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 = { leadingContent = {

View file

@ -9,8 +9,8 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@ -20,6 +20,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -133,7 +134,7 @@ fun PlaylistList(
properties = DialogProperties(usePlatformDefaultWidth = false), properties = DialogProperties(usePlatformDefaultWidth = false),
elevation = 10.dp, elevation = 10.dp,
) { ) {
val playlistName = rememberTextFieldState() var playlistName by rememberSaveable { mutableStateOf("") }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Column( Column(
@ -148,7 +149,8 @@ fun PlaylistList(
text = stringResource(R.string.name), text = stringResource(R.string.name),
) )
EditTextBox( EditTextBox(
state = playlistName, value = playlistName,
onValueChange = { playlistName = it },
keyboardOptions = keyboardOptions =
KeyboardOptions( KeyboardOptions(
capitalization = KeyboardCapitalization.Words, capitalization = KeyboardCapitalization.Words,
@ -156,10 +158,17 @@ fun PlaylistList(
keyboardType = KeyboardType.Text, keyboardType = KeyboardType.Text,
imeAction = ImeAction.Done, imeAction = ImeAction.Done,
), ),
onKeyboardAction = { keyboardActions =
showCreateDialog = false KeyboardActions(
onCreatePlaylist.invoke(playlistName.text.toString()) onDone = {
}, showCreateDialog = false
onCreatePlaylist.invoke(playlistName)
},
),
// onKeyboardAction = {
// showCreateDialog = false
// onCreatePlaylist.invoke(playlistName.text.toString())
// },
modifier = modifier =
Modifier Modifier
.focusRequester(focusRequester) .focusRequester(focusRequester)
@ -168,9 +177,9 @@ fun PlaylistList(
Button( Button(
onClick = { onClick = {
showCreateDialog = false showCreateDialog = false
onCreatePlaylist.invoke(playlistName.text.toString()) onCreatePlaylist.invoke(playlistName)
}, },
enabled = playlistName.text.isNotNullOrBlank(), enabled = playlistName.isNotNullOrBlank(),
modifier = Modifier, modifier = Modifier,
) { ) {
Text(text = stringResource(R.string.submit)) Text(text = stringResource(R.string.submit))

View file

@ -30,9 +30,7 @@ import androidx.lifecycle.compose.LifecycleStartEffect
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.model.BaseItem 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.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.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage 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.api.MediaType
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUID import org.jellyfin.sdk.model.serializer.toUUID
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID import java.util.UUID
import kotlin.time.Duration import kotlin.time.Duration
@ -157,7 +156,7 @@ fun EpisodeDetails(
watched = ep.data.userData?.played ?: false, watched = ep.data.userData?.played ?: false,
favorite = ep.data.userData?.isFavorite ?: false, favorite = ep.data.userData?.isFavorite ?: false,
seriesId = ep.data.seriesId, seriesId = ep.data.seriesId,
sourceId = chosenStreams?.sourceId, sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
actions = moreActions, actions = moreActions,
onChooseVersion = { onChooseVersion = {
chooseVersion = chooseVersion =
@ -174,23 +173,36 @@ fun EpisodeDetails(
moreDialog = null moreDialog = null
}, },
onChooseTracks = { type -> onChooseTracks = { type ->
chooseSource( viewModel.streamChoiceService
ep.data, .chooseSource(
chosenStreams?.itemPlayback, ep.data,
)?.let { source -> chosenStreams?.itemPlayback,
chooseVersion = )?.let { source ->
chooseStream( chooseVersion =
context = context, chooseStream(
streams = source.mediaStreams.orEmpty(), context = context,
type = type, streams = source.mediaStreams.orEmpty(),
onClick = { trackIndex -> type = type,
viewModel.saveTrackSelection( onClick = { trackIndex ->
ep, viewModel.saveTrackSelection(
chosenStreams?.itemPlayback, ep,
trackIndex, chosenStreams?.itemPlayback,
type, 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() focusRequesters.getOrNull(position)?.tryRequestFocus()
} }
Box(modifier = modifier) { Box(modifier = modifier) {
DetailsBackdropImage(ep)
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp), contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp),

View file

@ -58,9 +58,7 @@ fun EpisodeDetailsHeader(
EpisodeQuickDetails(dto) EpisodeQuickDetails(dto)
VideoStreamDetails( VideoStreamDetails(
preferences = preferences, chosenStreams = chosenStreams,
dto = dto,
itemPlayback = chosenStreams?.itemPlayback,
modifier = Modifier.padding(bottom = padding), modifier = Modifier.padding(bottom = padding),
) )
dto.taglines?.firstOrNull()?.let { tagline -> dto.taglines?.firstOrNull()?.let { tagline ->

View file

@ -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.BaseItem
import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.preferences.ThemeSongVolume 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.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager 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.ThemeSongPlayer
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setValueOnMain
@ -44,8 +47,11 @@ class EpisodeViewModel
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
val serverRepository: ServerRepository, val serverRepository: ServerRepository,
val itemPlaybackRepository: ItemPlaybackRepository, val itemPlaybackRepository: ItemPlaybackRepository,
val streamChoiceService: StreamChoiceService,
private val themeSongPlayer: ThemeSongPlayer, private val themeSongPlayer: ThemeSongPlayer,
private val favoriteWatchManager: FavoriteWatchManager, private val favoriteWatchManager: FavoriteWatchManager,
private val userPreferencesService: UserPreferencesService,
private val backdropService: BackdropService,
@Assisted val itemId: UUID, @Assisted val itemId: UUID,
) : ViewModel() { ) : ViewModel() {
@AssistedFactory @AssistedFactory
@ -85,12 +91,14 @@ class EpisodeViewModel
"Error fetching movie", "Error fetching movie",
), ),
) { ) {
val prefs = userPreferencesService.getCurrent()
val item = fetchAndSetItem().await() val item = fetchAndSetItem().await()
val result = itemPlaybackRepository.getSelectedTracks(item.id, item) val result = itemPlaybackRepository.getSelectedTracks(item.id, item, prefs)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@EpisodeViewModel.item.value = item this@EpisodeViewModel.item.value = item
chosenStreams.value = result chosenStreams.value = result
loading.value = LoadingState.Success loading.value = LoadingState.Success
backdropService.submit(item)
} }
} }
@ -115,10 +123,12 @@ class EpisodeViewModel
sourceId: UUID, sourceId: UUID,
) { ) {
viewModelScope.launchIO { viewModelScope.launchIO {
val prefs = userPreferencesService.getCurrent()
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId) val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
val chosen = val chosen =
result?.let { result?.let {
itemPlaybackRepository.getChosenItemFromPlayback(item, result) itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
chosenStreams.value = chosen chosenStreams.value = chosen
@ -133,6 +143,8 @@ class EpisodeViewModel
type: MediaStreamType, type: MediaStreamType,
) { ) {
viewModelScope.launchIO { viewModelScope.launchIO {
val prefs = userPreferencesService.getCurrent()
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result = val result =
itemPlaybackRepository.saveTrackSelection( itemPlaybackRepository.saveTrackSelection(
item = item, item = item,
@ -142,7 +154,7 @@ class EpisodeViewModel
) )
val chosen = val chosen =
result?.let { result?.let {
itemPlaybackRepository.getChosenItemFromPlayback(item, result) itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
chosenStreams.value = chosen chosenStreams.value = chosen

View file

@ -10,8 +10,10 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@ -20,60 +22,99 @@ import androidx.tv.material3.Text
import androidx.tv.material3.contentColorFor import androidx.tv.material3.contentColorFor
import androidx.tv.material3.surfaceColorAtElevation import androidx.tv.material3.surfaceColorAtElevation
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.FontAwesome
import java.time.LocalDateTime
@Composable @Composable
fun Program( fun Program(
guideStart: LocalDateTime,
program: TvProgram, program: TvProgram,
focused: Boolean, focused: Boolean,
colorCode: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val background = val background =
if (focused) { if (focused) {
MaterialTheme.colorScheme.inverseSurface MaterialTheme.colorScheme.inverseSurface
} else { } else if (colorCode) {
program.category?.color program.category?.color
?: MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp) ?: MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)
} else {
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)
} }
val textColor = MaterialTheme.colorScheme.contentColorFor(background) 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( Box(
modifier = modifier =
modifier modifier
.padding(2.dp) .padding(2.dp)
.fillMaxSize() .fillMaxSize()
.background( .background(
background, color = background,
shape = RoundedCornerShape(4.dp), shape = shape,
), ),
) { ) {
Column( Row(
verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxSize(),
modifier =
Modifier
.fillMaxSize()
.padding(4.dp),
) { ) {
Text( if (startedBeforeGuide) {
text = program.name ?: program.id.toString(), Text(
color = textColor, text = stringResource(R.string.fa_caret_left),
fontSize = 16.sp, fontFamily = FontAwesome,
maxLines = 1, color = textColor,
overflow = TextOverflow.Ellipsis, fontSize = 16.sp,
modifier = Modifier, modifier =
) Modifier
listOfNotNull( .align(Alignment.CenterVertically)
program.seasonEpisode?.let { "S${it.season} E${it.episode}" }, .padding(start = 2.dp),
program.subtitle, )
).joinToString(" - ") }
.ifBlank { null } Column(
?.let { verticalArrangement = Arrangement.spacedBy(4.dp),
Text( modifier =
text = it, Modifier
color = textColor, .fillMaxSize()
fontSize = 14.sp, .padding(start = 2.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
overflow = TextOverflow.Ellipsis, ) {
modifier = Modifier, 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( RecordingMarker(
isRecording = program.isRecording, isRecording = program.isRecording,

View file

@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.detail.livetv
import android.content.Context import android.content.Context
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.datastore.core.DataStore
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.WholphinApplication
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.data.RowColumn 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.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job 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.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock 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.BaseItemDto
import org.jellyfin.sdk.model.api.GetProgramsDto import org.jellyfin.sdk.model.api.GetProgramsDto
import org.jellyfin.sdk.model.api.ImageType 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.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.TimerInfoDto import org.jellyfin.sdk.model.api.TimerInfoDto
import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
@ -45,11 +55,13 @@ import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.min import kotlin.math.min
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
const val MAX_HOURS = 48L const val MAX_HOURS = 48L
@OptIn(FlowPreview::class)
@HiltViewModel @HiltViewModel
class LiveTvViewModel class LiveTvViewModel
@Inject @Inject
@ -57,15 +69,15 @@ class LiveTvViewModel
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
val api: ApiClient, val api: ApiClient,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
val preferences: DataStore<AppPreferences>,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
) : ViewModel() { ) : ViewModel() {
val loading = MutableLiveData<LoadingState>(LoadingState.Pending) val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
lateinit var guideStart: LocalDateTime
private set
private lateinit var channelsIdToIndex: Map<UUID, Int> private lateinit var channelsIdToIndex: Map<UUID, Int>
private val mutex = Mutex() private val mutex = Mutex()
val guideTimes = MutableLiveData<List<LocalDateTime>>(buildGuideTimes())
val channels = MutableLiveData<List<TvChannel>>() val channels = MutableLiveData<List<TvChannel>>()
val channelProgramCount = mutableMapOf<UUID, Int>() val channelProgramCount = mutableMapOf<UUID, Int>()
val programs = MutableLiveData<FetchedPrograms>() val programs = MutableLiveData<FetchedPrograms>()
@ -75,11 +87,25 @@ class LiveTvViewModel
private val range = 100 private val range = 100
fun init(firstLoad: Boolean) { init {
guideStart = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS) viewModelScope.launchIO {
if (!firstLoad) { preferences.data
loading.value = LoadingState.Loading .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( viewModelScope.launch(
Dispatchers.IO + Dispatchers.IO +
LoadingExceptionHandler( LoadingExceptionHandler(
@ -87,11 +113,26 @@ class LiveTvViewModel
"Could not fetch channels", "Could not fetch channels",
), ),
) { ) {
val prefs =
(preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance())
.interfacePreferences.liveTvPreferences
val channelData by api.liveTvApi.getLiveTvChannels( val channelData by api.liveTvApi.getLiveTvChannels(
GetLiveTvChannelsRequest( GetLiveTvChannelsRequest(
startIndex = 0, startIndex = 0,
userId = serverRepository.currentUser.value?.id, 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, 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 // 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 // This makes the guide appear faster, and load more usable data in the background
val initial = 10 val initial = 10
fetchPrograms(channels, 0..<initial.coerceAtMost(channels.size)) fetchPrograms(guideStart, channels, 0..<initial.coerceAtMost(channels.size))
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@LiveTvViewModel.channels.value = channels this@LiveTvViewModel.channels.value = channels
@ -119,21 +160,35 @@ class LiveTvViewModel
} }
// Now load the full range // Now load the full range
if (channels.size > initial) { if (channels.size > initial) {
fetchPrograms(channels, 0..<range.coerceAtMost(channels.size)) fetchPrograms(guideStart, channels, 0..<range.coerceAtMost(channels.size))
} }
} }
} }
private fun buildGuideTimes() =
buildList {
val start = LocalDateTime.now().roundDownToHalfHour()
add(start)
if (start.minute == 30) {
add(start.plusMinutes(30))
}
repeat(MAX_HOURS.toInt() - 1) {
add(last().plusHours(1))
}
}
private suspend fun fetchProgramsWithLoading( private suspend fun fetchProgramsWithLoading(
channels: List<TvChannel>, channels: List<TvChannel>,
range: IntRange, range: IntRange,
) { ) {
loading.setValueOnMain(LoadingState.Loading) loading.setValueOnMain(LoadingState.Loading)
fetchPrograms(channels, range) val guideStart = guideTimes.value!!.first()
fetchPrograms(guideStart, channels, range)
loading.setValueOnMain(LoadingState.Success) loading.setValueOnMain(LoadingState.Success)
} }
private suspend fun fetchPrograms( private suspend fun fetchPrograms(
guideStart: LocalDateTime,
channels: List<TvChannel>, channels: List<TvChannel>,
range: IntRange, range: IntRange,
) = mutex.withLock { ) = mutex.withLock {
@ -148,6 +203,7 @@ class LiveTvViewModel
channelIds = channelsToFetch.map { it.id }, channelIds = channelsToFetch.map { it.id },
sortBy = listOf(ItemSortBy.START_DATE), sortBy = listOf(ItemSortBy.START_DATE),
userId = serverRepository.currentUser.value?.id, userId = serverRepository.currentUser.value?.id,
fields = listOf(ItemFields.OVERVIEW),
) )
val fetchedPrograms = val fetchedPrograms =
api.liveTvApi api.liveTvApi
@ -173,6 +229,12 @@ class LiveTvViewModel
} else { } else {
null 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 = val p =
TvProgram( TvProgram(
id = dto.id, id = dto.id,
@ -186,8 +248,10 @@ class LiveTvViewModel
).coerceAtLeast(0f), ).coerceAtLeast(0f),
endHours = hoursBetween(guideStart, dto.endDate!!), endHours = hoursBetween(guideStart, dto.endDate!!),
duration = dto.runTimeTicks!!.ticks, duration = dto.runTimeTicks!!.ticks,
name = dto.seriesName ?: dto.name, name = name,
subtitle = dto.episodeTitle.takeIf { dto.isSeries ?: false }, subtitle = subtitle,
overview = dto.overview,
officialRating = dto.officialRating,
seasonEpisode = seasonEpisode =
if (dto.indexNumber != null && dto.parentIndexNumber != null) { if (dto.indexNumber != null && dto.parentIndexNumber != null) {
SeasonEpisode( SeasonEpisode(
@ -474,11 +538,13 @@ data class TvProgram(
val duration: Duration, val duration: Duration,
val name: String?, val name: String?,
val subtitle: String?, val subtitle: String?,
val overview: String? = null,
val seasonEpisode: SeasonEpisode?, val seasonEpisode: SeasonEpisode?,
val isRecording: Boolean, val isRecording: Boolean,
val isSeriesRecording: Boolean, val isSeriesRecording: Boolean,
val isRepeat: Boolean, val isRepeat: Boolean,
val category: ProgramCategory?, val category: ProgramCategory?,
val officialRating: String? = null,
) { ) {
val isFake = category == ProgramCategory.FAKE val isFake = category == ProgramCategory.FAKE
@ -500,6 +566,7 @@ data class TvProgram(
duration = ((endHours - startHours) * 60).toInt().minutes, duration = ((endHours - startHours) * 60).toInt().minutes,
name = NO_DATA, name = NO_DATA,
subtitle = null, subtitle = null,
overview = null,
seasonEpisode = null, seasonEpisode = null,
isRecording = false, isRecording = false,
isSeriesRecording = false, isSeriesRecording = false,
@ -524,3 +591,8 @@ data class FetchedPrograms(
val programs: List<TvProgram>, val programs: List<TvProgram>,
val programsByChannel: Map<UUID, List<TvProgram>>, val programsByChannel: Map<UUID, List<TvProgram>>,
) )
fun LocalDateTime.roundDownToHalfHour(): LocalDateTime {
val min = minute % 30L
return minusMinutes(min).truncatedTo(ChronoUnit.MINUTES)
}

View file

@ -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<AppPreferences, Any>
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,
)
}
}
}
}

View file

@ -2,10 +2,13 @@ package com.github.damontecres.wholphin.ui.detail.livetv
import android.text.format.DateUtils import android.text.format.DateUtils
import android.widget.Toast import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -24,6 +28,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.key.Key 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.Surface
import androidx.tv.material3.SurfaceDefaults import androidx.tv.material3.SurfaceDefaults
import androidx.tv.material3.Text 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.CircularProgress
import com.github.damontecres.wholphin.ui.components.ErrorMessage 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.components.LoadingPage
import com.github.damontecres.wholphin.ui.data.RowColumn 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.nav.Destination
import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
@ -66,16 +76,21 @@ fun TvGuideGrid(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: LiveTvViewModel = hiltViewModel(), viewModel: LiveTvViewModel = hiltViewModel(),
) { ) {
var firstLoad by rememberSaveable { mutableStateOf(true) } val scope = rememberCoroutineScope()
LaunchedEffect(Unit) { // var firstLoad by rememberSaveable { mutableStateOf(true) }
viewModel.init(firstLoad) // LaunchedEffect(Unit) {
firstLoad = false // viewModel.init(firstLoad)
} // firstLoad = false
// }
val loading by viewModel.loading.observeAsState(LoadingState.Pending) val loading by viewModel.loading.observeAsState(LoadingState.Pending)
val channels by viewModel.channels.observeAsState(listOf()) val channels by viewModel.channels.observeAsState(listOf())
val programs by viewModel.programs.observeAsState(FetchedPrograms(0..0, listOf(), mapOf())) val programs by viewModel.programs.observeAsState(FetchedPrograms(0..0, listOf(), mapOf()))
// val programsByChannel by viewModel.programsByChannel.observeAsState(mapOf()) // val programsByChannel by viewModel.programsByChannel.observeAsState(mapOf())
// val fetchedRange by viewModel.fetchedRange.observeAsState(0..0) // 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) { when (val state = loading) {
is LoadingState.Error -> { is LoadingState.Error -> {
ErrorMessage(state, modifier) ErrorMessage(state, modifier)
@ -90,25 +105,59 @@ fun TvGuideGrid(
LoadingState.Success, LoadingState.Success,
-> { -> {
val context = LocalContext.current val context = LocalContext.current
val guideTimes by viewModel.guideTimes.observeAsState(listOf())
val fetchedItem by viewModel.fetchedItem.observeAsState(null) val fetchedItem by viewModel.fetchedItem.observeAsState(null)
val loadingItem by viewModel.fetchingItem.observeAsState(LoadingState.Pending) val loadingItem by viewModel.fetchingItem.observeAsState(LoadingState.Pending)
var showItemDialog by remember { mutableStateOf<Int?>(null) } var showItemDialog by remember { mutableStateOf<Int?>(null) }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val buttonFocusRequester = remember { FocusRequester() }
if (requestFocusAfterLoading) { if (requestFocusAfterLoading) {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
focusRequester.tryRequestFocus() 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()) { if (channels.isEmpty()) {
ErrorMessage("Live TV is enabled, but no channels were found.", null) ErrorMessage("Live TV is enabled, but no channels were found.", null)
} else { } 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( TvGuideGridContent(
preferences = tvPrefs,
loading = state is LoadingState.Loading, loading = state is LoadingState.Loading,
channels = channels, channels = channels,
programs = programs, programs = programs,
channelProgramCount = viewModel.channelProgramCount, channelProgramCount = viewModel.channelProgramCount,
start = viewModel.guideStart, guideTimes = guideTimes,
onClickChannel = { index, channel -> onClickChannel = { index, channel ->
viewModel.navigationManager.navigateTo( viewModel.navigationManager.navigateTo(
Destination.Playback( Destination.Playback(
@ -118,6 +167,7 @@ fun TvGuideGrid(
) )
}, },
onFocus = { onFocus = {
focusedPosition = it
onRowPosition.invoke(it.row) onRowPosition.invoke(it.row)
viewModel.onFocusChannel(it) viewModel.onFocusChannel(it)
}, },
@ -144,6 +194,7 @@ fun TvGuideGrid(
showItemDialog = index showItemDialog = index
} }
}, },
buttonFocusRequester = buttonFocusRequester,
modifier = modifier =
Modifier Modifier
.fillMaxSize() .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 @Composable
fun TvGuideGridContent( fun TvGuideGridContent(
preferences: LiveTvPreferences,
loading: Boolean, loading: Boolean,
channels: List<TvChannel>, channels: List<TvChannel>,
programs: FetchedPrograms, programs: FetchedPrograms,
channelProgramCount: Map<UUID, Int>, channelProgramCount: Map<UUID, Int>,
start: LocalDateTime, guideTimes: List<LocalDateTime>,
onClickChannel: (Int, TvChannel) -> Unit, onClickChannel: (Int, TvChannel) -> Unit,
onClickProgram: (Int, TvProgram) -> Unit, onClickProgram: (Int, TvProgram) -> Unit,
onFocus: (RowColumn) -> Unit, onFocus: (RowColumn) -> Unit,
buttonFocusRequester: FocusRequester,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val state = rememberSaveableProgramGuideState() val state = rememberSaveableProgramGuideState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val guideStart = guideTimes.first()
var focusedItem by rememberPosition(RowColumn(0, 0)) var focusedItem by rememberPosition(RowColumn(0, 0))
val focusedChannelIndex = focusedItem.row val focusedChannelIndex = focusedItem.row
@ -229,25 +305,19 @@ fun TvGuideGridContent(
state.animateToProgram(focusedProgramIndex, Alignment.Center) 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 gridHasFocus by rememberSaveable { mutableStateOf(false) }
var channelColumnFocused by rememberSaveable { mutableStateOf(false) } var channelColumnFocused by rememberSaveable { mutableStateOf(false) }
Box(modifier = modifier) { Box(modifier = modifier) {
ProgramGuide( ProgramGuide(
state = state, state = state,
dimensions = dimensions, dimensions = tvGuideDimensions,
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.onFocusChanged { .onFocusChanged {
gridHasFocus = it.hasFocus gridHasFocus = it.hasFocus
}.focusProperties {
up = buttonFocusRequester
}.focusable() }.focusable()
.onPreviewKeyEvent { .onPreviewKeyEvent {
if (it.type == KeyEventType.KeyUp) { if (it.type == KeyEventType.KeyUp) {
@ -291,7 +361,7 @@ fun TvGuideGridContent(
Key.DirectionUp -> { Key.DirectionUp -> {
if (item.row <= 0) { if (item.row <= 0) {
focusManager.moveFocus(FocusDirection.Up) // focusManager.moveFocus(FocusDirection.Up)
null null
} else { } else {
val newChannelIndex = item.row - 1 val newChannelIndex = item.row - 1
@ -438,7 +508,7 @@ fun TvGuideGridContent(
currentTime( currentTime(
layoutInfo = { layoutInfo = {
ProgramGuideItem.CurrentTime( ProgramGuideItem.CurrentTime(
hoursBetween(start, LocalDateTime.now()), hoursBetween(guideStart, LocalDateTime.now()),
) )
}, },
) { ) {
@ -455,11 +525,18 @@ fun TvGuideGridContent(
} }
} }
timeline( timeline(
count = MAX_HOURS.toInt(), count = guideTimes.size,
layoutInfo = { index -> layoutInfo = { index ->
val start = guideTimes[index]
val end =
if (index < guideTimes.lastIndex) {
guideTimes[index + 1]
} else {
start.plusHours(1)
}
ProgramGuideItem.Timeline( ProgramGuideItem.Timeline(
startHour = index.toFloat(), startHour = hoursBetween(guideStart, start),
endHour = index + 1f, endHour = hoursBetween(guideStart, end),
) )
}, },
) { index -> ) { index ->
@ -477,16 +554,12 @@ fun TvGuideGridContent(
shape = RoundedCornerShape(4.dp), shape = RoundedCornerShape(4.dp),
), ),
) { ) {
val differentDay = val guideTime = guideTimes[index]
start.toLocalDate() != val differentDay = guideTime.toLocalDate() != guideStart.toLocalDate()
start
.plusHours(index.toLong())
.toLocalDate()
val time = val time =
DateUtils.formatDateTime( DateUtils.formatDateTime(
context, context,
start guideTime
.plusHours(index.toLong())
.toInstant(OffsetDateTime.now().offset) .toInstant(OffsetDateTime.now().offset)
.epochSecond * 1000, .epochSecond * 1000,
DateUtils.FORMAT_SHOW_TIME or if (differentDay) DateUtils.FORMAT_SHOW_WEEKDAY else 0, 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 program = programs.programs[programIndex]
val focused = val focused =
gridHasFocus && !channelColumnFocused && programIndex == focusedProgramIndex gridHasFocus && !channelColumnFocused && programIndex == focusedProgramIndex
Program(program, focused, Modifier) Program(guideStart, program, focused, preferences.colorCodePrograms, Modifier)
} }
channels( channels(

View file

@ -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,
)
}
}
}

View file

@ -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.RemoteTrailer
import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.data.model.aspectRatioFloat 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.preferences.UserPreferences
import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.ui.AspectRatios 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.ItemRow
import com.github.damontecres.wholphin.ui.cards.PersonRow import com.github.damontecres.wholphin.ui.cards.PersonRow
import com.github.damontecres.wholphin.ui.cards.SeasonCard 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.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage 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.api.MediaType
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUID import org.jellyfin.sdk.model.serializer.toUUID
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID import java.util.UUID
import kotlin.time.Duration import kotlin.time.Duration
@ -204,7 +203,7 @@ fun MovieDetails(
watched = movie.data.userData?.played ?: false, watched = movie.data.userData?.played ?: false,
favorite = movie.data.userData?.isFavorite ?: false, favorite = movie.data.userData?.isFavorite ?: false,
seriesId = null, seriesId = null,
sourceId = chosenStreams?.sourceId, sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
actions = moreActions, actions = moreActions,
onChooseVersion = { onChooseVersion = {
chooseVersion = chooseVersion =
@ -221,25 +220,37 @@ fun MovieDetails(
moreDialog = null moreDialog = null
}, },
onChooseTracks = { type -> onChooseTracks = { type ->
chooseSource( viewModel.streamChoiceService
movie.data, .chooseSource(
chosenStreams?.itemPlayback, movie.data,
)?.let { source -> chosenStreams?.itemPlayback,
chooseVersion = )?.let { source ->
chooseStream( chooseVersion =
context = context, chooseStream(
streams = source.mediaStreams.orEmpty(), context = context,
type = type, streams = source.mediaStreams.orEmpty(),
onClick = { trackIndex -> type = type,
viewModel.saveTrackSelection( onClick = { trackIndex ->
movie, viewModel.saveTrackSelection(
chosenStreams?.itemPlayback, movie,
trackIndex, chosenStreams?.itemPlayback,
type, 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() focusRequesters.getOrNull(position)?.tryRequestFocus()
} }
Box(modifier = modifier) { Box(modifier = modifier) {
DetailsBackdropImage(movie)
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp), contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp),

View file

@ -13,6 +13,7 @@ import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle 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.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -53,6 +54,7 @@ fun MovieDetailsHeader(
text = movie.name ?: "", text = movie.name ?: "",
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.displaySmall, style = MaterialTheme.typography.displaySmall,
fontWeight = FontWeight.SemiBold,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(.75f), modifier = Modifier.fillMaxWidth(.75f),
@ -70,9 +72,7 @@ fun MovieDetailsHeader(
} }
VideoStreamDetails( VideoStreamDetails(
preferences = preferences, chosenStreams = chosenStreams,
dto = dto,
itemPlayback = chosenStreams?.itemPlayback,
modifier = Modifier.padding(bottom = padding), modifier = Modifier.padding(bottom = padding),
) )
dto.taglines?.firstOrNull()?.let { tagline -> dto.taglines?.firstOrNull()?.let { tagline ->

View file

@ -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.Person
import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.preferences.ThemeSongVolume 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.ExtrasService
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PeopleFavorites 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.ThemeSongPlayer
import com.github.damontecres.wholphin.services.TrailerService 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.SlimItemFields
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
@ -54,11 +57,14 @@ class MovieViewModel
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
val serverRepository: ServerRepository, val serverRepository: ServerRepository,
val itemPlaybackRepository: ItemPlaybackRepository, val itemPlaybackRepository: ItemPlaybackRepository,
val streamChoiceService: StreamChoiceService,
private val themeSongPlayer: ThemeSongPlayer, private val themeSongPlayer: ThemeSongPlayer,
private val favoriteWatchManager: FavoriteWatchManager, private val favoriteWatchManager: FavoriteWatchManager,
private val peopleFavorites: PeopleFavorites, private val peopleFavorites: PeopleFavorites,
private val trailerService: TrailerService, private val trailerService: TrailerService,
private val extrasService: ExtrasService, private val extrasService: ExtrasService,
private val userPreferencesService: UserPreferencesService,
private val backdropService: BackdropService,
@Assisted val itemId: UUID, @Assisted val itemId: UUID,
) : ViewModel() { ) : ViewModel() {
@AssistedFactory @AssistedFactory
@ -104,11 +110,17 @@ class MovieViewModel
), ),
) { ) {
val item = fetchAndSetItem().await() val item = fetchAndSetItem().await()
val result = itemPlaybackRepository.getSelectedTracks(item.id, item) val result =
itemPlaybackRepository.getSelectedTracks(
item.id,
item,
userPreferencesService.getCurrent(),
)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@MovieViewModel.item.value = item this@MovieViewModel.item.value = item
chosenStreams.value = result chosenStreams.value = result
loading.value = LoadingState.Success loading.value = LoadingState.Success
backdropService.submit(item)
} }
viewModelScope.launchIO { viewModelScope.launchIO {
val trailers = trailerService.getTrailers(item) val trailers = trailerService.getTrailers(item)
@ -172,10 +184,12 @@ class MovieViewModel
sourceId: UUID, sourceId: UUID,
) { ) {
viewModelScope.launchIO { viewModelScope.launchIO {
val prefs = userPreferencesService.getCurrent()
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId) val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
val chosen = val chosen =
result?.let { result?.let {
itemPlaybackRepository.getChosenItemFromPlayback(item, result) itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
chosenStreams.value = chosen chosenStreams.value = chosen
@ -190,6 +204,8 @@ class MovieViewModel
type: MediaStreamType, type: MediaStreamType,
) { ) {
viewModelScope.launchIO { viewModelScope.launchIO {
val prefs = userPreferencesService.getCurrent()
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result = val result =
itemPlaybackRepository.saveTrackSelection( itemPlaybackRepository.saveTrackSelection(
item = item, item = item,
@ -199,7 +215,7 @@ class MovieViewModel
) )
val chosen = val chosen =
result?.let { result?.let {
itemPlaybackRepository.getChosenItemFromPlayback(item, result) itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
chosenStreams.value = chosen chosenStreams.value = chosen

View file

@ -37,9 +37,7 @@ fun FocusedEpisodeHeader(
if (dto != null) { if (dto != null) {
VideoStreamDetails( VideoStreamDetails(
preferences = preferences, chosenStreams = chosenStreams,
dto = dto,
itemPlayback = chosenStreams?.itemPlayback,
modifier = Modifier, modifier = Modifier,
) )
} }

View file

@ -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.PersonRow
import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.cards.SeasonCard
import com.github.damontecres.wholphin.ui.components.ConfirmDialog 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.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.DialogPopup
@ -320,8 +319,6 @@ fun SeriesDetailsContent(
Box( Box(
modifier = modifier, modifier = modifier,
) { ) {
DetailsBackdropImage(series)
Column( Column(
modifier = modifier =
Modifier Modifier

View file

@ -21,7 +21,6 @@ import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.lifecycle.map import androidx.lifecycle.map
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.chooseSource
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.components.DialogParams 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.Serializable
import kotlinx.serialization.UseSerializers import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.PersonKind import org.jellyfin.sdk.model.api.PersonKind
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.UUIDSerializer import org.jellyfin.sdk.model.serializer.UUIDSerializer
import org.jellyfin.sdk.model.serializer.toUUID import org.jellyfin.sdk.model.serializer.toUUID
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
import kotlin.time.Duration import kotlin.time.Duration
@ -219,7 +218,7 @@ fun SeriesOverview(
watched = ep.data.userData?.played ?: false, watched = ep.data.userData?.played ?: false,
favorite = ep.data.userData?.isFavorite ?: false, favorite = ep.data.userData?.isFavorite ?: false,
seriesId = series.id, seriesId = series.id,
sourceId = chosenStreams?.sourceId, sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
actions = actions =
MoreDialogActions( MoreDialogActions(
navigateTo = viewModel::navigateTo, navigateTo = viewModel::navigateTo,
@ -257,25 +256,35 @@ fun SeriesOverview(
moreDialog = null moreDialog = null
}, },
onChooseTracks = { type -> onChooseTracks = { type ->
chooseSource( viewModel.streamChoiceService
ep.data, .chooseSource(
chosenStreams?.itemPlayback, ep.data,
)?.let { source -> chosenStreams?.itemPlayback,
chooseVersion = )?.let { source ->
chooseStream( chooseVersion =
context = context, chooseStream(
streams = source.mediaStreams.orEmpty(), context = context,
type = type, streams = source.mediaStreams.orEmpty(),
onClick = { trackIndex -> type = type,
viewModel.saveTrackSelection( onClick = { trackIndex ->
ep, viewModel.saveTrackSelection(
chosenStreams?.itemPlayback, ep,
trackIndex, chosenStreams?.itemPlayback,
type, 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, chosenStreams = chosenStreams,
peopleInEpisode = peopleInEpisode, peopleInEpisode = peopleInEpisode,
position = position, position = position,
backdropImageUrl =
remember {
viewModel.imageUrl(
series.id,
ImageType.BACKDROP,
)
},
firstItemFocusRequester = firstItemFocusRequester, firstItemFocusRequester = firstItemFocusRequester,
episodeRowFocusRequester = episodeRowFocusRequester, episodeRowFocusRequester = episodeRowFocusRequester,
castCrewRowFocusRequester = castCrewRowFocusRequester, castCrewRowFocusRequester = castCrewRowFocusRequester,

View file

@ -52,7 +52,6 @@ import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.cards.BannerCard import com.github.damontecres.wholphin.ui.cards.BannerCard
import com.github.damontecres.wholphin.ui.cards.PersonRow 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.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.SeriesName import com.github.damontecres.wholphin.ui.components.SeriesName
@ -75,7 +74,6 @@ fun SeriesOverviewContent(
chosenStreams: ChosenStreams?, chosenStreams: ChosenStreams?,
peopleInEpisode: List<Person>, peopleInEpisode: List<Person>,
position: SeriesOverviewPosition, position: SeriesOverviewPosition,
backdropImageUrl: String?,
firstItemFocusRequester: FocusRequester, firstItemFocusRequester: FocusRequester,
episodeRowFocusRequester: FocusRequester, episodeRowFocusRequester: FocusRequester,
castCrewRowFocusRequester: FocusRequester, castCrewRowFocusRequester: FocusRequester,
@ -112,7 +110,6 @@ fun SeriesOverviewContent(
modifier modifier
.fillMaxWidth(), .fillMaxWidth(),
) { ) {
DetailsBackdropImage(backdropImageUrl)
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier =
@ -218,9 +215,7 @@ fun SeriesOverviewContent(
item = episode, item = episode,
aspectRatio = aspectRatio =
episode episode
?.data ?.aspectRatio
?.primaryImageAspectRatio
?.toFloat()
?.coerceAtLeast(AspectRatios.FOUR_THREE) ?.coerceAtLeast(AspectRatios.FOUR_THREE)
?: (AspectRatios.WIDE), ?: (AspectRatios.WIDE),
cornerText = cornerText, cornerText = cornerText,

View file

@ -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.data.model.Trailer
import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.preferences.ThemeSongVolume
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.ExtrasService
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PeopleFavorites 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.ThemeSongPlayer
import com.github.damontecres.wholphin.services.TrailerService 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.SlimItemFields
import com.github.damontecres.wholphin.ui.detail.ItemViewModel import com.github.damontecres.wholphin.ui.detail.ItemViewModel
import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.equalsNotNull
@ -70,6 +73,9 @@ class SeriesViewModel
private val peopleFavorites: PeopleFavorites, private val peopleFavorites: PeopleFavorites,
private val trailerService: TrailerService, private val trailerService: TrailerService,
private val extrasService: ExtrasService, private val extrasService: ExtrasService,
val streamChoiceService: StreamChoiceService,
private val userPreferencesService: UserPreferencesService,
private val backdropService: BackdropService,
) : ItemViewModel(api) { ) : ItemViewModel(api) {
private lateinit var seriesId: UUID private lateinit var seriesId: UUID
private lateinit var prefs: UserPreferences private lateinit var prefs: UserPreferences
@ -99,6 +105,7 @@ class SeriesViewModel
) + Dispatchers.IO, ) + Dispatchers.IO,
) { ) {
val item = fetchItem(seriesId) val item = fetchItem(seriesId)
backdropService.submit(item)
val seasons = getSeasons(item) val seasons = getSeasons(item)
// If a particular season was requested, fetch those episodes, otherwise get the first season // If a particular season was requested, fetch those episodes, otherwise get the first season
@ -328,6 +335,8 @@ class SeriesViewModel
episodes.value = eps 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?.cancel()
chosenStreamsJob = chosenStreamsJob =
viewModelScope.launchIO { viewModelScope.launchIO {
val result = itemPlaybackRepository.getSelectedTracks(itemId, item) val result =
itemPlaybackRepository.getSelectedTracks(
itemId,
item,
userPreferencesService.getCurrent(),
)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
chosenStreams.value = result chosenStreams.value = result
} }
@ -384,10 +398,12 @@ class SeriesViewModel
sourceId: UUID, sourceId: UUID,
) { ) {
viewModelScope.launchIO { viewModelScope.launchIO {
val prefs = userPreferencesService.getCurrent()
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId) val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
val chosen = val chosen =
result?.let { result?.let {
itemPlaybackRepository.getChosenItemFromPlayback(item, result) itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
chosenStreams.value = chosen chosenStreams.value = chosen
@ -402,6 +418,8 @@ class SeriesViewModel
type: MediaStreamType, type: MediaStreamType,
) { ) {
viewModelScope.launchIO { viewModelScope.launchIO {
val prefs = userPreferencesService.getCurrent()
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result = val result =
itemPlaybackRepository.saveTrackSelection( itemPlaybackRepository.saveTrackSelection(
item = item, item = item,
@ -411,7 +429,7 @@ class SeriesViewModel
) )
val chosen = val chosen =
result?.let { result?.let {
itemPlaybackRepository.getChosenItemFromPlayback(item, result) itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
chosenStreams.value = chosen chosenStreams.value = chosen

View file

@ -34,6 +34,7 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight 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.BannerCard
import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.DelayedDetailsBackdropImage
import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.EpisodeQuickDetails 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.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank 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.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.MediaType
import timber.log.Timber
import java.util.UUID import java.util.UUID
@Composable @Composable
@ -157,8 +161,12 @@ fun HomePage(
items = dialogItems, items = dialogItems,
) )
}, },
onClickPlay = { _, item ->
viewModel.navigationManager.navigateTo(Destination.Playback(item))
},
loadingState = refreshing, loadingState = refreshing,
showClock = preferences.appPreferences.interfacePreferences.showClock, showClock = preferences.appPreferences.interfacePreferences.showClock,
onUpdateBackdrop = viewModel::updateBackdrop,
modifier = modifier, modifier = modifier,
) )
dialog?.let { params -> dialog?.let { params ->
@ -193,7 +201,9 @@ fun HomePageContent(
homeRows: List<HomeRowLoadingState>, homeRows: List<HomeRowLoadingState>,
onClickItem: (RowColumn, BaseItem) -> Unit, onClickItem: (RowColumn, BaseItem) -> Unit,
onLongClickItem: (RowColumn, BaseItem) -> Unit, onLongClickItem: (RowColumn, BaseItem) -> Unit,
onClickPlay: (RowColumn, BaseItem) -> Unit,
showClock: Boolean, showClock: Boolean,
onUpdateBackdrop: (BaseItem) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onFocusPosition: ((RowColumn) -> Unit)? = null, onFocusPosition: ((RowColumn) -> Unit)? = null,
loadingState: LoadingState? = null, loadingState: LoadingState? = null,
@ -240,12 +250,10 @@ fun HomePageContent(
LaunchedEffect(position) { LaunchedEffect(position) {
listState.animateScrollToItem(position.row) listState.animateScrollToItem(position.row)
} }
LaunchedEffect(focusedItem) {
focusedItem?.let(onUpdateBackdrop)
}
Box(modifier = modifier) { Box(modifier = modifier) {
DelayedDetailsBackdropImage(
item = focusedItem,
modifier = Modifier,
)
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
HomePageHeader( HomePageHeader(
item = focusedItem, item = focusedItem,
@ -264,7 +272,18 @@ fun HomePageContent(
top = 0.dp, top = 0.dp,
bottom = Cards.height2x3, 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 -> itemsIndexed(homeRows) { rowIndex, row ->
when (val r = row) { when (val r = row) {

View file

@ -9,10 +9,12 @@ import com.github.damontecres.wholphin.data.NavDrawerItemRepository
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.DatePlayedService
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.SlimItemFields 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.nav.ServerNavDrawerItem
import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
@ -58,6 +60,7 @@ class HomeViewModel
val navDrawerItemRepository: NavDrawerItemRepository, val navDrawerItemRepository: NavDrawerItemRepository,
private val favoriteWatchManager: FavoriteWatchManager, private val favoriteWatchManager: FavoriteWatchManager,
private val datePlayedService: DatePlayedService, private val datePlayedService: DatePlayedService,
private val backdropService: BackdropService,
) : ViewModel() { ) : ViewModel() {
val loadingState = MutableLiveData<LoadingState>(LoadingState.Pending) val loadingState = MutableLiveData<LoadingState>(LoadingState.Pending)
val refreshState = MutableLiveData<LoadingState>(LoadingState.Pending) val refreshState = MutableLiveData<LoadingState>(LoadingState.Pending)
@ -316,6 +319,12 @@ class HomeViewModel
init(preferences) init(preferences)
} }
} }
fun updateBackdrop(item: BaseItem) {
viewModelScope.launchIO {
backdropService.submit(item)
}
}
} }
val supportedLatestCollectionTypes = val supportedLatestCollectionTypes =

View file

@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -196,7 +195,8 @@ fun SearchPage(
val episodes by viewModel.episodes.observeAsState(SearchResult.NoQuery) val episodes by viewModel.episodes.observeAsState(SearchResult.NoQuery)
val seerrResults by viewModel.seerrResults.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() } val focusRequester = remember { FocusRequester() }
var position by rememberPosition() var position by rememberPosition()
@ -204,7 +204,7 @@ fun SearchPage(
LaunchedEffect(query) { LaunchedEffect(query) {
delay(750L) delay(750L)
viewModel.search(query.text.toString()) viewModel.search(query)
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
focusRequester.tryRequestFocus() focusRequester.tryRequestFocus()
@ -238,9 +238,10 @@ fun SearchPage(
focusManager.moveFocus(FocusDirection.Next) focusManager.moveFocus(FocusDirection.Next)
} }
SearchEditTextBox( SearchEditTextBox(
state = query, value = query,
onValueChange = { query = it },
onSearchClick = { onSearchClick = {
viewModel.search(query.text.toString()) viewModel.search(query)
searchClicked = true searchClicked = true
}, },
modifier = modifier =

View file

@ -1,17 +1,59 @@
package com.github.damontecres.wholphin.ui.nav 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay 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.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser 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.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager 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.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 * This is generally the root composable of the of the app
@ -25,37 +67,158 @@ fun ApplicationContent(
navigationManager: NavigationManager, navigationManager: NavigationManager,
preferences: UserPreferences, preferences: UserPreferences,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: ApplicationContentViewModel = hiltViewModel(),
) { ) {
NavDisplay( val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle()
backStack = navigationManager.backStack, val backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle
onBack = { navigationManager.goBack() }, Box(
entryDecorators = modifier = modifier,
listOf( ) {
rememberSaveableStateHolderNavEntryDecorator(), val baseBackgroundColor = MaterialTheme.colorScheme.background
rememberViewModelStoreNavEntryDecorator(), if (backdrop.hasColors &&
), (backdropStyle == BackdropStyle.BACKDROP_DYNAMIC_COLOR || backdropStyle == BackdropStyle.UNRECOGNIZED)
entryProvider = { key -> ) {
key as Destination val animPrimary by animateColorAsState(
val contentKey = "${key}_${server?.id}_${user?.id}" backdrop.primaryColor,
NavEntry(key, contentKey = contentKey) { animationSpec = tween(1250),
if (key.fullScreen) { label = "dynamic_backdrop_primary",
DestinationContent( )
destination = key, val animSecondary by animateColorAsState(
preferences = preferences, backdrop.secondaryColor,
modifier = modifier.fillMaxSize(), animationSpec = tween(1250),
) label = "dynamic_backdrop_secondary",
} else if (user != null && server != null) { )
NavDrawer( val animTertiary by animateColorAsState(
destination = key, backdrop.tertiaryColor,
preferences = preferences, animationSpec = tween(1250),
user = user, label = "dynamic_backdrop_tertiary",
server = server, )
modifier = modifier, Box(
) modifier =
} else { Modifier
ErrorMessage("Trying to go to $key without a user logged in", null) .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)
}
}
},
)
}
} }

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.ui.nav package com.github.damontecres.wholphin.ui.nav
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.filter.DefaultForGenresFilterOptions import com.github.damontecres.wholphin.data.filter.DefaultForGenresFilterOptions
@ -44,8 +45,12 @@ import timber.log.Timber
fun DestinationContent( fun DestinationContent(
destination: Destination, destination: Destination,
preferences: UserPreferences, preferences: UserPreferences,
onClearBackdrop: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (destination.fullScreen) {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
}
when (destination) { when (destination) {
is Destination.Home -> { is Destination.Home -> {
HomePage( HomePage(
@ -125,6 +130,7 @@ fun DestinationContent(
} }
BaseItemKind.BOX_SET -> { BaseItemKind.BOX_SET -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
CollectionFolderBoxSet( CollectionFolderBoxSet(
preferences = preferences, preferences = preferences,
itemId = destination.itemId, itemId = destination.itemId,
@ -136,6 +142,7 @@ fun DestinationContent(
} }
BaseItemKind.PLAYLIST -> { BaseItemKind.PLAYLIST -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
PlaylistDetails( PlaylistDetails(
destination = destination, destination = destination,
modifier = modifier, modifier = modifier,
@ -143,6 +150,7 @@ fun DestinationContent(
} }
BaseItemKind.COLLECTION_FOLDER -> { BaseItemKind.COLLECTION_FOLDER -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
CollectionFolder( CollectionFolder(
preferences = preferences, preferences = preferences,
destination = destination, destination = destination,
@ -154,6 +162,7 @@ fun DestinationContent(
} }
BaseItemKind.FOLDER -> { BaseItemKind.FOLDER -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
CollectionFolder( CollectionFolder(
preferences = preferences, preferences = preferences,
destination = destination, destination = destination,
@ -165,6 +174,7 @@ fun DestinationContent(
} }
BaseItemKind.USER_VIEW -> { BaseItemKind.USER_VIEW -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
CollectionFolder( CollectionFolder(
preferences = preferences, preferences = preferences,
destination = destination, destination = destination,
@ -176,6 +186,7 @@ fun DestinationContent(
} }
BaseItemKind.PERSON -> { BaseItemKind.PERSON -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
PersonPage( PersonPage(
preferences, preferences,
destination, destination,
@ -191,6 +202,7 @@ fun DestinationContent(
} }
is Destination.FilteredCollection -> { is Destination.FilteredCollection -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
CollectionFolderGeneric( CollectionFolderGeneric(
preferences = preferences, preferences = preferences,
itemId = destination.itemId, itemId = destination.itemId,
@ -204,6 +216,7 @@ fun DestinationContent(
} }
is Destination.Recordings -> { is Destination.Recordings -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
CollectionFolderRecordings( CollectionFolderRecordings(
preferences, preferences,
destination.itemId, destination.itemId,
@ -213,6 +226,7 @@ fun DestinationContent(
} }
is Destination.ItemGrid -> { is Destination.ItemGrid -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
ItemGrid( ItemGrid(
destination, destination,
modifier, modifier,
@ -220,6 +234,7 @@ fun DestinationContent(
} }
Destination.Favorites -> { Destination.Favorites -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
FavoritesPage( FavoritesPage(
preferences = preferences, preferences = preferences,
modifier = modifier, modifier = modifier,
@ -235,6 +250,7 @@ fun DestinationContent(
} }
Destination.Search -> { Destination.Search -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
SearchPage( SearchPage(
userPreferences = preferences, userPreferences = preferences,
modifier = modifier, modifier = modifier,

View file

@ -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.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.AppThemeColors import com.github.damontecres.wholphin.preferences.AppThemeColors
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.components.TimeDisplay import com.github.damontecres.wholphin.ui.components.TimeDisplay
@ -105,6 +106,7 @@ class NavDrawerViewModel
constructor( constructor(
private val navDrawerItemRepository: NavDrawerItemRepository, private val navDrawerItemRepository: NavDrawerItemRepository,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
val backdropService: BackdropService,
) : ViewModel() { ) : ViewModel() {
private var all: List<NavDrawerItem>? = null private var all: List<NavDrawerItem>? = null
val moreLibraries = MutableLiveData<List<NavDrawerItem>>(null) val moreLibraries = MutableLiveData<List<NavDrawerItem>>(null)
@ -217,6 +219,7 @@ fun NavDrawer(
preferences: UserPreferences, preferences: UserPreferences,
user: JellyfinUser, user: JellyfinUser,
server: JellyfinServer, server: JellyfinServer,
onClearBackdrop: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: NavDrawerViewModel = viewModel: NavDrawerViewModel =
hiltViewModel( hiltViewModel(
@ -314,11 +317,9 @@ fun NavDrawer(
val drawerPadding by animateDpAsState(if (drawerState.isOpen) 0.dp else 8.dp) val drawerPadding by animateDpAsState(if (drawerState.isOpen) 0.dp else 8.dp)
val drawerBackground by animateColorAsState( val drawerBackground by animateColorAsState(
if (drawerState.isOpen) { if (drawerState.isOpen) {
MaterialTheme.colorScheme.surfaceColorAtElevation(
1.dp,
)
} else {
MaterialTheme.colorScheme.surface MaterialTheme.colorScheme.surface
} else {
Color.Transparent
}, },
) )
val spacedBy = 4.dp val spacedBy = 4.dp
@ -542,6 +543,7 @@ fun NavDrawer(
DestinationContent( DestinationContent(
destination = destination, destination = destination,
preferences = preferences, preferences = preferences,
onClearBackdrop = onClearBackdrop,
modifier = modifier =
Modifier Modifier
.fillMaxSize(), .fillMaxSize(),

View file

@ -6,6 +6,7 @@ import com.github.damontecres.wholphin.util.TrackSupport
import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.PlayMethod import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.TranscodingInfo import org.jellyfin.sdk.model.api.TranscodingInfo
import kotlin.time.Duration
data class CurrentPlayback( data class CurrentPlayback(
val item: BaseItem, val item: BaseItem,
@ -18,4 +19,5 @@ data class CurrentPlayback(
val videoDecoder: String? = null, val videoDecoder: String? = null,
val audioDecoder: String? = null, val audioDecoder: String? = null,
val transcodeInfo: TranscodingInfo? = null, val transcodeInfo: TranscodingInfo? = null,
val subtitleDelay: Duration = Duration.ZERO,
) )

View file

@ -11,14 +11,18 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
@ -102,7 +106,8 @@ fun DownloadSubtitlesContent(
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
val lang = rememberTextFieldState(language) // val lang = rememberTextFieldState(language)
var lang by rememberSaveable { mutableStateOf("") }
Row( Row(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@ -113,10 +118,17 @@ fun DownloadSubtitlesContent(
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
EditTextBox( EditTextBox(
state = lang, value = lang,
onKeyboardAction = { onValueChange = { lang = it },
onSearch(lang.text.toString()) keyboardActions =
}, KeyboardActions(
onSearch = {
onSearch(lang)
},
),
// onKeyboardAction = {
// onSearch(lang.text.toString())
// },
keyboardOptions = keyboardOptions =
KeyboardOptions( KeyboardOptions(
imeAction = ImeAction.Search, imeAction = ImeAction.Search,

View file

@ -98,7 +98,7 @@ fun NextUpEpisode(
modifier = modifier =
Modifier Modifier
.fillMaxHeight() .fillMaxHeight()
.aspectRatio(aspectRatio) .aspectRatio(aspectRatio.coerceAtLeast(AspectRatios.MIN))
// .fillMaxSize() // .fillMaxSize()
// .weight(1f) // .weight(1f)
.focusRequester(focusRequester), .focusRequester(focusRequester),

View file

@ -1,13 +1,26 @@
package com.github.damontecres.wholphin.ui.playback package com.github.damontecres.wholphin.ui.playback
import android.view.Gravity 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.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource 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.R
import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import timber.log.Timber import timber.log.Timber
import kotlin.time.Duration
enum class PlaybackDialogType { enum class PlaybackDialogType {
MORE, MORE,
@ -16,6 +29,7 @@ enum class PlaybackDialogType {
AUDIO, AUDIO,
PLAYBACK_SPEED, PLAYBACK_SPEED,
VIDEO_SCALE, VIDEO_SCALE,
SUBTITLE_DELAY,
} }
data class PlaybackSettings( data class PlaybackSettings(
@ -26,16 +40,19 @@ data class PlaybackSettings(
val subtitleStreams: List<SubtitleStream>, val subtitleStreams: List<SubtitleStream>,
val playbackSpeed: Float, val playbackSpeed: Float,
val contentScale: ContentScale, val contentScale: ContentScale,
val subtitleDelay: Duration,
) )
@Composable @Composable
fun PlaybackDialog( fun PlaybackDialog(
enableSubtitleDelay: Boolean,
type: PlaybackDialogType, type: PlaybackDialogType,
settings: PlaybackSettings, settings: PlaybackSettings,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
onControllerInteraction: () -> Unit, onControllerInteraction: () -> Unit,
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit, onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
onPlaybackActionClick: (PlaybackAction) -> Unit, onPlaybackActionClick: (PlaybackAction) -> Unit,
onChangeSubtitleDelay: (Duration) -> Unit,
) { ) {
when (type) { when (type) {
PlaybackDialogType.MORE -> { PlaybackDialogType.MORE -> {
@ -97,11 +114,14 @@ fun PlaybackDialog(
PlaybackDialogType.SETTINGS -> { PlaybackDialogType.SETTINGS -> {
val options = val options =
listOf( buildList {
stringResource(R.string.audio), add(stringResource(R.string.audio))
stringResource(R.string.playback_speed), add(stringResource(R.string.playback_speed))
stringResource(R.string.video_scale), add(stringResource(R.string.video_scale))
) if (enableSubtitleDelay) {
add(stringResource(R.string.subtitle_delay))
}
}
BottomDialog( BottomDialog(
choices = options, choices = options,
currentChoice = null, currentChoice = null,
@ -111,6 +131,7 @@ fun PlaybackDialog(
0 -> onClickPlaybackDialogType(PlaybackDialogType.AUDIO) 0 -> onClickPlaybackDialogType(PlaybackDialogType.AUDIO)
1 -> onClickPlaybackDialogType(PlaybackDialogType.PLAYBACK_SPEED) 1 -> onClickPlaybackDialogType(PlaybackDialogType.PLAYBACK_SPEED)
2 -> onClickPlaybackDialogType(PlaybackDialogType.VIDEO_SCALE) 2 -> onClickPlaybackDialogType(PlaybackDialogType.VIDEO_SCALE)
3 -> onClickPlaybackDialogType(PlaybackDialogType.SUBTITLE_DELAY)
} }
}, },
gravity = Gravity.END, gravity = Gravity.END,
@ -173,5 +194,31 @@ fun PlaybackDialog(
gravity = Gravity.END, 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),
)
}
}
}
} }
} }

View file

@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -34,6 +35,8 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
@ -127,10 +130,37 @@ fun PlaybackOverlay(
var controllerHeight by remember { mutableStateOf(0.dp) } var controllerHeight by remember { mutableStateOf(0.dp) }
var state by remember { mutableStateOf(OverlayViewState.CONTROLLER) } 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( Box(
modifier = modifier, modifier = modifier,
contentAlignment = Alignment.BottomCenter, contentAlignment = Alignment.BottomCenter,
) { ) {
AnimatedVisibility(
visible = controllerViewState.controlsVisible,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.matchParentSize(),
) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(scrimBrush),
)
}
AnimatedVisibility( AnimatedVisibility(
state == OverlayViewState.CONTROLLER, state == OverlayViewState.CONTROLLER,
enter = slideInVertically() + fadeIn(), enter = slideInVertically() + fadeIn(),
@ -398,17 +428,13 @@ fun PlaybackOverlay(
val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight
val index = val index =
(seekProgressMs / trickplayInfo.interval).toInt() / tilesPerImage (seekProgressMs / trickplayInfo.interval).toInt() / tilesPerImage
val imageUrl = trickplayUrlFor(index) val imageUrl = remember(index) { trickplayUrlFor(index) }
if (imageUrl != null) { if (imageUrl != null) {
SeekPreviewImage( SeekPreviewImage(
modifier = modifier = Modifier,
Modifier,
previewImageUrl = imageUrl, previewImageUrl = imageUrl,
duration = playerControls.duration,
seekProgressMs = seekProgressMs, seekProgressMs = seekProgressMs,
videoWidth = trickplayInfo.width,
videoHeight = trickplayInfo.height,
trickPlayInfo = trickplayInfo, trickPlayInfo = trickplayInfo,
) )
} }

View file

@ -79,11 +79,13 @@ import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.Media3SubtitleOverride import com.github.damontecres.wholphin.util.Media3SubtitleOverride
import com.github.damontecres.wholphin.util.mpv.MpvPlayer
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import java.util.UUID import java.util.UUID
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -163,6 +165,11 @@ fun PlaybackPage(
var playbackSpeed by remember { mutableFloatStateOf(1.0f) } var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) } LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) }
val subtitleDelay = currentPlayback?.subtitleDelay ?: Duration.ZERO
LaunchedEffect(subtitleDelay) {
(player as? MpvPlayer)?.subtitleDelay = subtitleDelay
}
val presentationState = rememberPresentationState(player, false) val presentationState = rememberPresentationState(player, false)
val scaledModifier = val scaledModifier =
Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp) 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 // The playback controls
AnimatedVisibility( AnimatedVisibility(
controllerViewState.controlsVisible, controllerViewState.controlsVisible,
@ -395,6 +371,37 @@ fun PlaybackPage(
showClock = preferences.appPreferences.interfacePreferences.showClock, 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 // Ask to skip intros, etc button
@ -473,9 +480,7 @@ fun PlaybackPage(
).joinToString(" - "), ).joinToString(" - "),
description = it.data.overview, description = it.data.overview,
imageUrl = LocalImageUrlService.current.rememberImageUrl(it), imageUrl = LocalImageUrlService.current.rememberImageUrl(it),
aspectRatio = aspectRatio = it.aspectRatio ?: AspectRatios.WIDE,
it.data.primaryImageAspectRatio?.toFloat()
?: AspectRatios.WIDE,
onClick = { onClick = {
viewModel.reportInteraction() viewModel.reportInteraction()
viewModel.playNextUp() viewModel.playNextUp()
@ -545,6 +550,7 @@ fun PlaybackPage(
subtitleStreams = mediaInfo?.subtitleStreams.orEmpty(), subtitleStreams = mediaInfo?.subtitleStreams.orEmpty(),
playbackSpeed = playbackSpeed, playbackSpeed = playbackSpeed,
contentScale = contentScale, contentScale = contentScale,
subtitleDelay = subtitleDelay,
), ),
onDismissRequest = { onDismissRequest = {
playbackDialog = null playbackDialog = null
@ -555,8 +561,16 @@ fun PlaybackPage(
onControllerInteraction = { onControllerInteraction = {
controllerViewState.pulseControls(Long.MAX_VALUE) 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, onPlaybackActionClick = onPlaybackActionClick,
onChangeSubtitleDelay = { viewModel.updateSubtitleDelay(it) },
enableSubtitleDelay = player is MpvPlayer,
) )
} }
} }

View file

@ -21,6 +21,8 @@ import androidx.media3.exoplayer.DecoderCounters
import androidx.media3.exoplayer.DecoderReuseEvaluation import androidx.media3.exoplayer.DecoderReuseEvaluation
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.analytics.AnalyticsListener 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.ItemPlaybackDao
import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ItemPlaybackRepository
import com.github.damontecres.wholphin.data.ServerRepository 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.ItemPlayback
import com.github.damontecres.wholphin.data.model.Playlist import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.data.model.TrackIndex 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.AppPreference
import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.ShowNextUpWhen 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.PlaylistCreationResult
import com.github.damontecres.wholphin.services.PlaylistCreator import com.github.damontecres.wholphin.services.PlaylistCreator
import com.github.damontecres.wholphin.services.RefreshRateService 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.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.onMain import com.github.damontecres.wholphin.ui.onMain
@ -93,6 +94,7 @@ import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.PlaybackInfoDto import org.jellyfin.sdk.model.api.PlaybackInfoDto
import org.jellyfin.sdk.model.api.PlaystateCommand import org.jellyfin.sdk.model.api.PlaystateCommand
import org.jellyfin.sdk.model.api.PlaystateMessage 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.inWholeTicks
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.jellyfin.sdk.model.serializer.toUUIDOrNull
@ -100,6 +102,7 @@ import timber.log.Timber
import java.util.Date import java.util.Date
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -123,6 +126,7 @@ class PlaybackViewModel
private val deviceInfo: DeviceInfo, private val deviceInfo: DeviceInfo,
private val deviceProfileService: DeviceProfileService, private val deviceProfileService: DeviceProfileService,
private val refreshRateService: RefreshRateService, private val refreshRateService: RefreshRateService,
val streamChoiceService: StreamChoiceService,
) : ViewModel(), ) : ViewModel(),
Player.Listener, Player.Listener,
AnalyticsListener { AnalyticsListener {
@ -225,9 +229,13 @@ class PlaybackViewModel
"Error preparing for playback for $itemId", "Error preparing for playback for $itemId",
), ),
) { ) {
val destItem = (destination as? Destination.Playback)?.item?.data
val queriedItem = val queriedItem =
(destination as? Destination.Playback)?.item?.data if (destItem?.mediaSources != null) {
?: api.userLibraryApi.getItem(itemId).content destItem
} else {
api.userLibraryApi.getItem(itemId).content
}
val base = val base =
if (queriedItem.type.playable) { if (queriedItem.type.playable) {
queriedItem 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) { if (mediaSource == null) {
showToast( showToast(
@ -361,13 +370,27 @@ class PlaybackViewModel
?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels }) ?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels })
.orEmpty() .orEmpty()
val audioIndex = val audioStream =
chooseStream(base, playbackConfig, MediaStreamType.AUDIO, preferences) streamChoiceService
?.index .chooseAudioStream(
source = mediaSource,
seriesId = base.seriesId,
itemPlayback = playbackConfig,
plc = plc,
prefs = preferences,
)
val audioIndex = audioStream?.index
val subtitleIndex = val subtitleIndex =
chooseStream(base, playbackConfig, MediaStreamType.SUBTITLE, preferences) streamChoiceService
?.index .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") Timber.d("Selected mediaSource=${mediaSource.id}, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex")
@ -385,6 +408,17 @@ class PlaybackViewModel
?.get(mediaSource.id) ?.get(mediaSource.id)
?.values ?.values
?.firstOrNull() ?.firstOrNull()
trickPlayInfo?.let { trickplayInfo ->
mediaSource.runTimeTicks?.ticks?.let { duration ->
viewModelScope.launchIO {
prefetchTrickplay(
duration,
trickplayInfo,
mediaSource.id?.toUUIDOrNull(),
)
}
}
}
val chapters = Chapter.fromDto(base, api) val chapters = Chapter.fromDto(base, api)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@ -481,7 +515,7 @@ class PlaybackViewModel
this@PlaybackViewModel.currentItemPlayback.value = itemPlayback this@PlaybackViewModel.currentItemPlayback.value = itemPlayback
} }
loadSubtitleDelay()
return@withContext return@withContext
} }
} else { } else {
@ -563,7 +597,7 @@ class PlaybackViewModel
else -> throw Exception("No supported playback method") else -> throw Exception("No supported playback method")
} }
Timber.v("Playback decision: $transcodeType") Timber.i("Playback decision for $itemId: $transcodeType")
val externalSubtitleCount = source.externalSubtitlesCount val externalSubtitleCount = source.externalSubtitlesCount
@ -605,21 +639,6 @@ class PlaybackViewModel
liveStreamId = source.liveStreamId, liveStreamId = source.liveStreamId,
mediaSourceInfo = source, 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) { if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) {
source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }?.let { source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }?.let {
@ -638,14 +657,13 @@ class PlaybackViewModel
api = api, api = api,
player = player, player = player,
playback = playback, playback = playback,
itemPlayback = itemPlayback, itemPlayback = currentItemPlayback,
) )
player.addListener(activityListener) player.addListener(activityListener)
this@PlaybackViewModel.activityListener = activityListener this@PlaybackViewModel.activityListener = activityListener
loading.value = LoadingState.Success loading.value = LoadingState.Success
this@PlaybackViewModel.currentPlayback.update { playback } this@PlaybackViewModel.currentPlayback.update { playback }
this@PlaybackViewModel.currentItemPlayback.value = itemPlayback
player.setMediaItem( player.setMediaItem(
mediaItem, mediaItem,
positionMs, positionMs,
@ -668,6 +686,7 @@ class PlaybackViewModel
if (result.bothSelected) { if (result.bothSelected) {
player.removeListener(this) player.removeListener(this)
} }
viewModelScope.launchIO { loadSubtitleDelay() }
} }
} }
} }
@ -679,41 +698,82 @@ class PlaybackViewModel
fun changeAudioStream(index: Int) { fun changeAudioStream(index: Int) {
viewModelScope.launchIO { 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( changeStreams(
item, item,
currentItemPlayback.value!!, itemPlayback,
index, index,
currentItemPlayback.value?.subtitleIndex, itemPlayback.subtitleIndex,
onMain { player.currentPosition }, onMain { player.currentPosition },
true, true,
) )
} }
} }
fun changeSubtitleStream(index: Int?): Job = fun changeSubtitleStream(index: Int): Job =
viewModelScope.launchIO { 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( changeStreams(
item, item,
currentItemPlayback.value!!, itemPlayback,
currentItemPlayback.value?.audioIndex, itemPlayback.audioIndex,
index, index,
onMain { player.currentPosition }, onMain { player.currentPosition },
true, true,
) )
} }
fun getTrickplayUrl(index: Int): String? { private suspend fun prefetchTrickplay(
val itemId = item.id duration: Duration,
val mediaSourceId = currentItemPlayback.value?.sourceId trickplayInfo: TrickplayInfo,
val trickPlayInfo = currentMediaInfo.value?.trickPlayInfo ?: return null mediaSourceId: UUID?,
return api.trickplayApi.getTrickplayTileImageUrl( ) {
itemId, val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight
trickPlayInfo.width, val totalCount =
index, (duration.inWholeMilliseconds / trickplayInfo.interval).toInt() / tilesPerImage + 1
mediaSourceId, (0..<totalCount).forEach {
) val url = getTrickplayUrl(it, trickplayInfo, mediaSourceId)
context.imageLoader.enqueue(
ImageRequest
.Builder(context)
.data(url)
.size(coil3.size.Size.ORIGINAL)
.build(),
)
}
} }
fun getTrickplayUrl(
index: Int,
trickPlayInfo: TrickplayInfo? = currentMediaInfo.value?.trickPlayInfo,
mediaSourceId: UUID? = currentItemPlayback.value?.sourceId,
): String? =
trickPlayInfo?.let {
val itemId = item.id
return api.trickplayApi.getTrickplayTileImageUrl(
itemId,
trickPlayInfo.width,
index,
mediaSourceId,
)
}
override fun onPlaybackStateChanged(playbackState: Int) { override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_ENDED) { if (playbackState == Player.STATE_ENDED) {
viewModelScope.launchIO { viewModelScope.launchIO {
@ -722,7 +782,7 @@ class PlaybackViewModel
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
nextUp.value = nextItem nextUp.value = nextItem
if (nextItem == null) { if (nextItem == null) {
controllerViewState.showControls() navigationManager.goBack()
} }
} }
} }
@ -1091,4 +1151,47 @@ class PlaybackViewModel
Timber.d("decoder: onAudioDisabled") Timber.d("decoder: onAudioDisabled")
currentPlayback.update { it?.copy(audioDecoder = null) } currentPlayback.update { it?.copy(audioDecoder = null) }
} }
private var subtitleDelaySaveJob: Job? = null
fun updateSubtitleDelay(delta: Duration) {
subtitleDelaySaveJob?.cancel()
currentPlayback.update {
it?.let {
val newDelay = it.subtitleDelay + delta
val result = it.copy(subtitleDelay = it.subtitleDelay + delta)
subtitleDelaySaveJob =
viewModelScope.launchIO {
// Debounce & save
currentItemPlayback.value?.let { item ->
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) }
}
}
}
}
} }

View file

@ -1,24 +1,29 @@
package com.github.damontecres.wholphin.ui.playback package com.github.damontecres.wholphin.ui.playback
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier 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.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.ContentScale
import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import coil3.compose.AsyncImage import coil3.compose.rememberAsyncImagePainter
import coil3.request.CachePolicy
import coil3.request.ImageRequest import coil3.request.ImageRequest
import coil3.request.transformations
import com.github.damontecres.wholphin.ui.CoilTrickplayTransformation
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import org.jellyfin.sdk.model.api.TrickplayInfo import org.jellyfin.sdk.model.api.TrickplayInfo
@ -70,54 +75,70 @@ fun Modifier.offsetByPercent(xPercentage: Float) =
@Composable @Composable
fun SeekPreviewImage( fun SeekPreviewImage(
previewImageUrl: String, previewImageUrl: String,
duration: Long,
seekProgressMs: Long, seekProgressMs: Long,
videoWidth: Int?,
videoHeight: Int?,
trickPlayInfo: TrickplayInfo, trickPlayInfo: TrickplayInfo,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
placeHolder: Painter? = null,
) { ) {
val context = LocalContext.current val context = LocalContext.current
if (previewImageUrl.isNotNullOrBlank() && if (previewImageUrl.isNotNullOrBlank()) {
videoWidth != null &&
videoHeight != null
) {
val height = 160.dp val height = 160.dp
val width = height * (videoWidth.toFloat() / videoHeight) val width = height * (trickPlayInfo.width.toFloat() / trickPlayInfo.height)
val heightPx = with(LocalDensity.current) { height.toPx().toInt() } val scale = with(LocalDensity.current) { width.toPx() / trickPlayInfo.width }
val widthPx = with(LocalDensity.current) { width.toPx().toInt() }
val index = (seekProgressMs.toDouble() / trickPlayInfo.interval).toInt() // Which tile val model =
val numberOfTitlesPerImage = trickPlayInfo.tileHeight * trickPlayInfo.tileWidth remember(previewImageUrl) {
val imageIndex = index % numberOfTitlesPerImage
AsyncImage(
modifier =
modifier
.width(width)
.height(height)
.background(Color.Black)
.border(1.5.dp, color = MaterialTheme.colorScheme.border),
model =
ImageRequest ImageRequest
.Builder(context) .Builder(context)
.data(previewImageUrl) .data(previewImageUrl)
.memoryCachePolicy(CachePolicy.ENABLED) .size(coil3.size.Size.ORIGINAL)
.transformations( .build()
CoilTrickplayTransformation( }
widthPx, val painter =
heightPx, rememberAsyncImagePainter(
trickPlayInfo.tileHeight, model = model,
trickPlayInfo.tileWidth, contentScale = ContentScale.None,
imageIndex, )
index, val index =
), (seekProgressMs.toDouble() / trickPlayInfo.interval).toInt() // Index of tile across images
).build(), val numberOfTilesPerImage = trickPlayInfo.tileHeight * trickPlayInfo.tileWidth
contentScale = ContentScale.None, val tileIndex =
contentDescription = null, index % numberOfTilesPerImage // Index of tile within the current image
placeholder = placeHolder, 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(),
),
)
}
}
}
}
}
} }
} }

View file

@ -61,6 +61,7 @@ fun SkipIndicator(
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
fontSize = 13.sp, fontSize = 13.sp,
style = MaterialTheme.typography.bodySmall,
text = abs(durationMs / 1000).toString(), text = abs(durationMs / 1000).toString(),
) )
} }

View file

@ -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,
)
}
}

View file

@ -6,7 +6,6 @@ import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.TrackIndex 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.launchIO
import com.github.damontecres.wholphin.ui.onMain import com.github.damontecres.wholphin.ui.onMain
import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setValueOnMain
@ -101,7 +100,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
api.userLibraryApi.getItem(itemId = it.itemId).content, api.userLibraryApi.getItem(itemId = it.itemId).content,
api, api,
) )
val mediaSource = chooseSource(item.data, it) val mediaSource = streamChoiceService.chooseSource(item.data, it)
if (mediaSource == null) { if (mediaSource == null) {
// This shouldn't happen, but just in case // This shouldn't happen, but just in case
showToast( showToast(

View file

@ -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.AppPreferences
import com.github.damontecres.wholphin.preferences.resetSubtitles import com.github.damontecres.wholphin.preferences.resetSubtitles
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
@ -37,6 +38,7 @@ class PreferencesViewModel
private val api: ApiClient, private val api: ApiClient,
val preferenceDataStore: DataStore<AppPreferences>, val preferenceDataStore: DataStore<AppPreferences>,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
val backdropService: BackdropService,
private val rememberTabManager: RememberTabManager, private val rememberTabManager: RememberTabManager,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val navDrawerItemRepository: NavDrawerItemRepository, private val navDrawerItemRepository: NavDrawerItemRepository,

View file

@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -38,9 +39,14 @@ fun StringInputDialog(
elevation: Dp = 3.dp, elevation: Dp = 3.dp,
) { ) {
val mutableValue = rememberTextFieldState(input.value ?: "") val mutableValue = rememberTextFieldState(input.value ?: "")
var mutableText by remember { mutableStateOf(input.value ?: "") }
// var mutableValue by remember { mutableStateOf(input.value ?: "") } // var mutableValue by remember { mutableStateOf(input.value ?: "") }
val onDone = { 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) } var showConfirm by remember { mutableStateOf(false) }
Dialog( Dialog(
@ -79,17 +85,34 @@ fun StringInputDialog(
color = MaterialTheme.colorScheme.onSecondaryContainer, color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier, modifier = Modifier,
) )
EditTextBox( if (input.maxLines > 1) {
state = mutableValue, EditTextBox(
keyboardOptions = input.keyboardOptions, state = mutableValue,
onKeyboardAction = { keyboardOptions = input.keyboardOptions,
onDone.invoke() onKeyboardAction = {
}, onDone.invoke()
leadingIcon = null, },
isInputValid = { true }, leadingIcon = null,
maxLines = input.maxLines, isInputValid = { true },
modifier = Modifier.fillMaxWidth(), 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( Row(
horizontalArrangement = Arrangement.SpaceAround, horizontalArrangement = Arrangement.SpaceAround,

View file

@ -1,8 +1,17 @@
package com.github.damontecres.wholphin.ui.setup 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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete 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.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties 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.Icon
import androidx.tv.material3.IconButtonDefaults import androidx.tv.material3.IconButtonDefaults
import androidx.tv.material3.ListItem import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.JellyfinServer 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.CircularProgress
import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogPopup 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),
)
}
}

View file

@ -1,16 +1,23 @@
package com.github.damontecres.wholphin.ui.setup package com.github.damontecres.wholphin.ui.setup
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.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.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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -22,6 +29,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.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.res.stringResource
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization 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.unit.dp
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.CircularProgress 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.EditTextBox
import com.github.damontecres.wholphin.ui.components.TextButton import com.github.damontecres.wholphin.ui.components.TextButton
import com.github.damontecres.wholphin.ui.dimAndBlur 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.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import org.jellyfin.sdk.model.api.PublicSystemInfo
@Composable @Composable
fun SwitchServerContent( fun SwitchServerContent(
@ -51,141 +62,185 @@ fun SwitchServerContent(
val servers by viewModel.servers.observeAsState(listOf()) val servers by viewModel.servers.observeAsState(listOf())
val serverStatus by viewModel.serverStatus.observeAsState(mapOf()) val serverStatus by viewModel.serverStatus.observeAsState(mapOf())
val discoveredServers by viewModel.discoveredServers.observeAsState(listOf())
var showAddServer by remember { mutableStateOf(false) } var showAddServer by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf<com.github.damontecres.wholphin.data.model.JellyfinServer?>(null) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.init() viewModel.init()
viewModel.discoverServers()
} }
Box( Box(
modifier = modifier.dimAndBlur(showAddServer), modifier = modifier.dimAndBlur(showAddServer || showDeleteDialog != null),
) { ) {
Row( Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = modifier =
Modifier Modifier
.fillMaxWidth()
// Center the content like the Select User screen
.align(Alignment.Center) .align(Alignment.Center)
.padding(32.dp), .padding(16.dp),
) { ) {
// Match SwitchUser header height (title + subtitle) to align icons vertically across screens
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp), 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(
text = stringResource(R.string.select_server), text = stringResource(R.string.select_server),
style = MaterialTheme.typography.displaySmall, style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
ServerList( // Invisible subtitle placeholder to mirror the server name line on the Select User screen
servers = servers, Text(
connectionStatus = serverStatus, text = "Server placeholder",
onSwitchServer = { style = MaterialTheme.typography.titleLarge,
viewModel.switchServer(it) color = Color.Transparent,
},
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),
),
) )
} }
// Discover
Column( // Horizontal scrollable list of server icons - centered
horizontalAlignment = Alignment.CenterHorizontally, Box(
verticalArrangement = Arrangement.spacedBy(8.dp), 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 =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(1f) .height(56.dp),
.padding(16.dp) // approximate TV button height
.background( )
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), }
shape = RoundedCornerShape(16.dp),
), // Delete server dialog
) { showDeleteDialog?.let { server ->
Text( DialogPopup(
text = stringResource(R.string.discovered_servers), showDialog = true,
style = MaterialTheme.typography.displaySmall, title = server.name ?: server.url,
color = MaterialTheme.colorScheme.onSurface, dialogItems =
) listOf(
if (discoveredServers.isEmpty()) { DialogItem(
Text( stringResource(R.string.switch_servers),
text = stringResource(R.string.searching), R.string.fa_arrow_left_arrow_right,
style = MaterialTheme.typography.titleMedium, ) {
color = MaterialTheme.colorScheme.onSurface, viewModel.switchServer(server)
) showDeleteDialog = null
} else {
ServerList(
servers = discoveredServers,
connectionStatus =
discoveredServers
.map { it.id }
.associateWith { ServerConnectionStatus.Success(PublicSystemInfo()) },
onSwitchServer = {
viewModel.addServer(it.url)
}, },
onTestServer = { DialogItem(
viewModel.testServer(it) stringResource(R.string.delete),
Icons.Default.Delete,
Color.Red.copy(alpha = .8f),
) {
viewModel.removeServer(server)
showDeleteDialog = null
}, },
onAddServer = {}, ),
onRemoveServer = {}, onDismissRequest = { showDeleteDialog = null },
allowAdd = false, dismissOnClick = true,
allowDelete = false, waitToLoad = true,
modifier = properties = DialogProperties(),
Modifier elevation = 5.dp,
.fillMaxWidth() )
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
shape = RoundedCornerShape(16.dp),
),
)
}
}
} }
if (showAddServer) { if (showAddServer) {
var showEnterAddress by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.clearAddServerState() viewModel.clearAddServerState()
if (!showEnterAddress) {
viewModel.discoverServers()
}
} }
val state by viewModel.addServerState.observeAsState(LoadingState.Pending)
val url = rememberTextFieldState() val discoveredServers by viewModel.discoveredServers.observeAsState(listOf())
val submit = {
viewModel.addServer(url.text.toString()) // Filter out duplicates within the discovered servers list (same URL appearing multiple times)
val filteredDiscoveredServers =
remember(discoveredServers) {
val seenUrls = mutableSetOf<String>()
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( BasicDialog(
onDismissRequest = { onDismissRequest = {
showAddServer = false showAddServer = false
showEnterAddress = false
viewModel.clearAddServerState() viewModel.clearAddServerState()
}, },
properties = DialogProperties(usePlatformDefaultWidth = false), properties = DialogProperties(usePlatformDefaultWidth = false),
elevation = 10.dp, elevation = 10.dp,
) { ) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
@ -194,45 +249,134 @@ fun SwitchServerContent(
.padding(16.dp) .padding(16.dp)
.fillMaxWidth(.4f), .fillMaxWidth(.4f),
) { ) {
Text( if (!showEnterAddress) {
text = stringResource(R.string.enter_server_url), // Show discovered servers first
) Text(
EditTextBox( text = stringResource(R.string.discovered_servers),
state = url, style = MaterialTheme.typography.titleMedium,
keyboardOptions = color = MaterialTheme.colorScheme.onSurface,
KeyboardOptions( )
capitalization = KeyboardCapitalization.None,
autoCorrectEnabled = false, if (filteredDiscoveredServers.isEmpty() && discoveredServers.isEmpty()) {
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Go,
),
onKeyboardAction = { submit.invoke() },
modifier =
Modifier
.focusRequester(focusRequester)
.fillMaxWidth(),
)
when (val st = state) {
is LoadingState.Error -> {
Text( Text(
text = text = stringResource(R.string.searching),
st.message ?: st.exception?.localizedMessage style = MaterialTheme.typography.bodyMedium,
?: "An error occurred", color = MaterialTheme.colorScheme.onSurface,
color = MaterialTheme.colorScheme.error,
) )
} 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 = {
TextButton( showEnterAddress = true
onClick = { submit.invoke() }, },
enabled = url.text.isNotNullOrBlank() && state == LoadingState.Pending, modifier = Modifier.align(Alignment.CenterHorizontally),
modifier = Modifier, ) {
) { Text(text = stringResource(R.string.enter_server_address))
if (state == LoadingState.Loading) { }
CircularProgress(Modifier.size(32.dp)) } else {
} else { // Show enter server address form
Text(text = stringResource(R.string.submit)) 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))
}
} }
} }
} }

View file

@ -1,7 +1,6 @@
package com.github.damontecres.wholphin.ui.setup package com.github.damontecres.wholphin.ui.setup
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -36,7 +34,6 @@ import androidx.compose.ui.window.DialogProperties
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.JellyfinUser
@ -94,27 +91,28 @@ fun SwitchUserContent(
) { ) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = modifier =
Modifier Modifier
.fillMaxWidth(.5f) .fillMaxWidth()
.align(Alignment.Center) .align(Alignment.Center)
.padding(16.dp) .padding(16.dp),
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
shape = RoundedCornerShape(16.dp),
),
) { ) {
Text( Column(
text = stringResource(R.string.select_user), horizontalAlignment = Alignment.CenterHorizontally,
style = MaterialTheme.typography.displaySmall, verticalArrangement = Arrangement.spacedBy(8.dp),
color = MaterialTheme.colorScheme.onSurface, ) {
) Text(
Text( text = stringResource(R.string.select_user),
text = server.name ?: server.url, style = MaterialTheme.typography.displaySmall,
style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface,
color = MaterialTheme.colorScheme.onSurface, )
) Text(
text = server.name ?: server.url,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
}
UserList( UserList(
users = users, users = users,
currentUser = currentUser, currentUser = currentUser,
@ -134,13 +132,7 @@ fun SwitchUserContent(
onSwitchServer = { onSwitchServer = {
viewModel.navigationManager.navigateTo(Destination.ServerList) viewModel.navigationManager.navigateTo(Destination.ServerList)
}, },
modifier = modifier = Modifier.fillMaxWidth(),
Modifier
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
shape = RoundedCornerShape(16.dp),
),
) )
} }
} }
@ -170,7 +162,7 @@ fun SwitchUserContent(
Modifier Modifier
.focusGroup() .focusGroup()
.padding(16.dp) .padding(16.dp)
.fillMaxWidth(.66f), .fillMaxWidth(.4f),
) { ) {
if (useQuickConnect) { if (useQuickConnect) {
if (quickConnect == null && userState !is LoadingState.Error) { 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}", text = "Use Quick Connect on your device to authenticate to ${server.name ?: server.url}",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
) )
Text( Text(
text = quickConnect?.code ?: "Failed to get code", text = quickConnect?.code ?: "Failed to get code",
@ -215,13 +209,15 @@ fun SwitchUserContent(
modifier = Modifier.align(Alignment.CenterHorizontally), modifier = Modifier.align(Alignment.CenterHorizontally),
) )
} else { } else {
val username = rememberTextFieldState() // val username = rememberTextFieldState()
val password = rememberTextFieldState() // val password = rememberTextFieldState()
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
val onSubmit = { val onSubmit = {
viewModel.login( viewModel.login(
server, server,
username.text.toString(), username,
password.text.toString(), password,
) )
} }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
@ -231,6 +227,8 @@ fun SwitchUserContent(
text = "Enter username/password to login to ${server.name ?: server.url}", text = "Enter username/password to login to ${server.name ?: server.url}",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
) )
UserStateError(userState) UserStateError(userState)
Row( Row(
@ -242,7 +240,8 @@ fun SwitchUserContent(
modifier = Modifier.padding(end = 8.dp), modifier = Modifier.padding(end = 8.dp),
) )
EditTextBox( EditTextBox(
state = username, value = username,
onValueChange = { username = it },
keyboardOptions = keyboardOptions =
KeyboardOptions( KeyboardOptions(
capitalization = KeyboardCapitalization.None, capitalization = KeyboardCapitalization.None,
@ -250,9 +249,15 @@ fun SwitchUserContent(
keyboardType = KeyboardType.Text, keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next, imeAction = ImeAction.Next,
), ),
onKeyboardAction = { keyboardActions =
passwordFocusRequester.tryRequestFocus() KeyboardActions(
}, onNext = {
passwordFocusRequester.tryRequestFocus()
},
),
// onKeyboardAction = {
// passwordFocusRequester.tryRequestFocus()
// },
isInputValid = { userState !is LoadingState.Error }, isInputValid = { userState !is LoadingState.Error },
modifier = Modifier.focusRequester(focusRequester), modifier = Modifier.focusRequester(focusRequester),
) )
@ -265,12 +270,12 @@ fun SwitchUserContent(
text = "Password", text = "Password",
modifier = Modifier.padding(end = 8.dp), modifier = Modifier.padding(end = 8.dp),
) )
LaunchedEffect(password.text) { LaunchedEffect(password) {
viewModel.clearSwitchUserState() viewModel.clearSwitchUserState()
} }
EditTextBox( EditTextBox(
isPassword = true, value = password,
state = password, onValueChange = { password = it },
keyboardOptions = keyboardOptions =
KeyboardOptions( KeyboardOptions(
capitalization = KeyboardCapitalization.None, capitalization = KeyboardCapitalization.None,
@ -278,7 +283,10 @@ fun SwitchUserContent(
keyboardType = KeyboardType.Password, keyboardType = KeyboardType.Password,
imeAction = ImeAction.Go, imeAction = ImeAction.Go,
), ),
onKeyboardAction = { onSubmit.invoke() }, keyboardActions =
KeyboardActions(
onGo = { onSubmit.invoke() },
),
isInputValid = { userState !is LoadingState.Error }, isInputValid = { userState !is LoadingState.Error },
modifier = Modifier.focusRequester(passwordFocusRequester), modifier = Modifier.focusRequester(passwordFocusRequester),
) )
@ -286,7 +294,7 @@ fun SwitchUserContent(
TextButton( TextButton(
stringRes = R.string.login, stringRes = R.string.login,
onClick = { onSubmit.invoke() }, onClick = { onSubmit.invoke() },
enabled = username.text.isNotNullOrBlank() && password.text.isNotNullOrBlank(), enabled = username.isNotNullOrBlank(),
modifier = Modifier.align(Alignment.CenterHorizontally), modifier = Modifier.align(Alignment.CenterHorizontally),
) )
} }

View file

@ -7,6 +7,7 @@ import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.setValueOnMain 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.HttpClientOptions
import org.jellyfin.sdk.api.client.exception.InvalidStatusException import org.jellyfin.sdk.api.client.exception.InvalidStatusException
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName 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.quickConnectApi
import org.jellyfin.sdk.api.client.extensions.systemApi import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.api.client.extensions.userApi
@ -41,6 +43,7 @@ class SwitchUserViewModel
val serverRepository: ServerRepository, val serverRepository: ServerRepository,
val serverDao: JellyfinServerDao, val serverDao: JellyfinServerDao,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
val imageUrlService: ImageUrlService,
@Assisted val server: JellyfinServer, @Assisted val server: JellyfinServer,
) : ViewModel() { ) : ViewModel() {
@AssistedFactory @AssistedFactory
@ -50,7 +53,7 @@ class SwitchUserViewModel
val serverQuickConnect = MutableLiveData<Boolean>(false) val serverQuickConnect = MutableLiveData<Boolean>(false)
val users = MutableLiveData<List<JellyfinUser>>(listOf()) val users = MutableLiveData<List<JellyfinUserAndImage>>(listOf())
val quickConnectState = MutableLiveData<QuickConnectResult?>(null) val quickConnectState = MutableLiveData<QuickConnectResult?>(null)
private var quickConnectJob: Job? = null private var quickConnectJob: Job? = null
@ -78,8 +81,7 @@ class SwitchUserViewModel
quickConnectJob?.cancel() quickConnectJob?.cancel()
viewModelScope.launchIO { viewModelScope.launchIO {
users.setValueOnMain(listOf()) users.setValueOnMain(listOf())
val serverUsers = val serverUsers = getUsers()
serverDao.getServer(server.id)?.users?.sortedBy { it.name } ?: listOf()
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
users.setValueOnMain(serverUsers) users.setValueOnMain(serverUsers)
} }
@ -215,14 +217,23 @@ class SwitchUserViewModel
fun removeUser(user: JellyfinUser) { fun removeUser(user: JellyfinUser) {
viewModelScope.launchIO { viewModelScope.launchIO {
serverRepository.removeUser(user) serverRepository.removeUser(user)
val serverUsers = val serverUsers = getUsers()
serverDao.getServer(user.serverId)?.users?.sortedBy { it.name } ?: listOf()
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
users.value = serverUsers users.value = serverUsers
} }
} }
} }
private suspend fun getUsers(): List<JellyfinUserAndImage> {
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( private suspend fun setError(
msg: String? = null, msg: String? = null,
ex: Exception? = null, ex: Exception? = null,
@ -231,3 +242,8 @@ class SwitchUserViewModel
switchUserState.value = LoadingState.Error(msg, ex) switchUserState.value = LoadingState.Error(msg, ex)
} }
} }
data class JellyfinUserAndImage(
val user: JellyfinUser,
val imageUrl: String?,
)

View file

@ -1,37 +1,68 @@
package com.github.damontecres.wholphin.ui.setup package com.github.damontecres.wholphin.ui.setup
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.lazy.items 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.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties 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.Icon
import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.JellyfinUser 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.FontAwesome
import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogPopup 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 * 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 @Composable
fun UserList( fun UserList(
users: List<JellyfinUser>, users: List<JellyfinUserAndImage>,
currentUser: JellyfinUser?, currentUser: JellyfinUser?,
onSwitchUser: (JellyfinUser) -> Unit, onSwitchUser: (JellyfinUser) -> Unit,
onAddUser: () -> Unit, onAddUser: () -> Unit,
@ -41,59 +72,76 @@ fun UserList(
) { ) {
var showDeleteDialog by remember { mutableStateOf<JellyfinUser?>(null) } var showDeleteDialog by remember { mutableStateOf<JellyfinUser?>(null) }
LazyColumn(modifier = modifier) { Column(
items(users) { user -> modifier = modifier,
ListItem( horizontalAlignment = Alignment.CenterHorizontally,
enabled = true, verticalArrangement = Arrangement.spacedBy(24.dp),
selected = user == currentUser, ) {
headlineContent = { Text(text = user.name ?: user.id.toString()) }, // Horizontal scrollable list of user icons - centered
leadingContent = { Box(
if (user.id == currentUser?.id) { modifier = Modifier.fillMaxWidth(),
Icon( contentAlignment = Alignment.Center,
imageVector = Icons.Default.Check, ) {
contentDescription = "current user", val focusRequester = remember { FocusRequester() }
) val firstFocusRequester = remember { FocusRequester() }
} if (users.isNotEmpty()) {
}, LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
onClick = { onSwitchUser.invoke(user) }, }
onLongClick = { LazyRow(
showDeleteDialog = user 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, modifier =
) Modifier
} .wrapContentWidth()
item { .focusRestorer(firstFocusRequester)
HorizontalDivider() .focusRequester(focusRequester),
ListItem( ) {
enabled = true, itemsIndexed(users) { index, user ->
selected = false, UserIconCard(
headlineContent = { Text(text = stringResource(R.string.add_user)) }, user = user,
leadingContent = { isCurrentUser = user.user.id == currentUser?.id,
Icon( onClick = { onSwitchUser.invoke(user.user) },
imageVector = Icons.Default.Add, onLongClick = { showDeleteDialog = user.user },
tint = Color.Green.copy(alpha = .8f), modifier = if (index == 0) Modifier.focusRequester(firstFocusRequester) else Modifier,
contentDescription = null,
) )
}, }
onClick = { onAddUser.invoke() }, // Add User card - always rightmost
modifier = Modifier, item {
) AddUserCard(
onClick = { onAddUser.invoke() },
)
}
}
} }
item {
HorizontalDivider() // Switch servers button below user list - centered
ListItem( Row(
enabled = true, horizontalArrangement = Arrangement.Center,
selected = false, modifier =
headlineContent = { Text(text = stringResource(R.string.switch_servers)) }, Modifier
leadingContent = { .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(
text = stringResource(R.string.fa_arrow_left_arrow_right), text = stringResource(R.string.fa_arrow_left_arrow_right),
fontFamily = FontAwesome, fontFamily = FontAwesome,
modifier = Modifier.padding(end = 8.dp),
) )
}, Text(
onClick = { onSwitchServer.invoke() }, text = stringResource(R.string.switch_servers),
modifier = Modifier, textAlign = TextAlign.Center,
) )
}
}
} }
} }
showDeleteDialog?.let { user -> 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),
)
}
}

Some files were not shown because too many files have changed in this diff Show more