mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Merge branch 'main' into fea/jellyseerr
This commit is contained in:
commit
99d1ccf7e7
130 changed files with 8161 additions and 1622 deletions
12
.github/pull_request_template.md
vendored
Normal file
12
.github/pull_request_template.md
vendored
Normal 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
4
.gitignore
vendored
|
|
@ -53,7 +53,9 @@ app/libs/
|
|||
ffmpeg_decoder/
|
||||
app/release/
|
||||
app/debug/
|
||||
<<<<<<< HEAD
|
||||
.vscode
|
||||
.kotlin
|
||||
docs/
|
||||
|
||||
# mpv
|
||||
app/src/main/obj/
|
||||
|
|
|
|||
|
|
@ -18,3 +18,8 @@ repos:
|
|||
entry: val DEBUG = true
|
||||
language: pygrep
|
||||
files: '.+\.kt'
|
||||
- id: check-debug-logging-enabled
|
||||
name: Check debug enabled
|
||||
entry: debugLogging = true
|
||||
language: pygrep
|
||||
files: '.+\.kt'
|
||||
|
|
|
|||
|
|
@ -23,9 +23,17 @@ We follow GitHub's fork & pull request model for contributions.
|
|||
|
||||
After forking and cloning your fork, you can import the project into Android Studio.
|
||||
|
||||
You need a compatible Android Studio version for the configured AGP. This is generally `Narwhal 3 Feature Drop | 2025.1.3` or newer. See https://developer.android.com/build/releases/gradle-plugin and [`libs.versions.toml](./gradle/libs.versions.toml).
|
||||
### Development environment
|
||||
|
||||
### Code organization
|
||||
It is recommended to use a recent version of [Android Studio](https://developer.android.com/studio). Make sure the [version is compatible](https://developer.android.com/build/releases/gradle-plugin#android_gradle_plugin_and_android_studio_compatibility) with Wholphin's AGP version.
|
||||
|
||||
Code formatting should follow [ktlint's](https://github.com/pinterest/ktlint) rules. Find the `ktlint` version in [`.pre-commit-config.yaml`](./.pre-commit-config.yaml). Optionally, install the [ktlint plugin](https://plugins.jetbrains.com/plugin/15057-ktlint) in Android Studio to run automatically. Configure the version in `Settings->Tools->KtLint->Ruleset Version`.
|
||||
|
||||
Also, it's recommend to add an extra ruleset jar for Compose-specific KtLint: https://mrmans0n.github.io/compose-rules/ktlint/#using-with-ktlint-cli-or-the-ktlint-intellij-plugin
|
||||
|
||||
Also setup [pre-commit](https://github.com/pre-commit/pre-commit) which will run `ktlint` as well on each commit, plus check for other common issues.
|
||||
|
||||
## Code organization
|
||||
|
||||
Code is split into several packages:
|
||||
- `data` - app-specific data models and services
|
||||
|
|
|
|||
18
README.md
18
README.md
|
|
@ -7,15 +7,21 @@ Wholphin is an open-source Android TV client for Jellyfin. It aims to provide a
|
|||
This is not a fork of the [official client](https://github.com/jellyfin/jellyfin-androidtv). Wholphin's user interface and controls have been written completely from scratch. Wholphin `v0.3.0+` supports playing media using either ExoPlayer/Media3 or MPV (experimental).
|
||||
|
||||
<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">
|
||||
<img alt="Current Release" src="https://img.shields.io/github/release/damontecres/wholphin.svg"/>
|
||||
</a>
|
||||
<a href="https://translate.codeberg.org/engage/wholphin/">
|
||||
<img src="https://translate.codeberg.org/widget/wholphin/wholphin/svg-badge.svg" alt="Translation status" />
|
||||
</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>
|
||||
|
||||
<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
|
||||
|
||||
- A navigation drawer for quick access to libraries, search, and settings from almost anywhere in the app
|
||||
- A navigation drawer for quick access to libraries, favorites, search, and settings from almost anywhere in the app
|
||||
- Option to combine Continue Watching & Next Up rows
|
||||
- Show Movie/TV Show titles when browsing libraries
|
||||
- Play theme music, if available
|
||||
- Search & download subtitles (requires compatible server plugin such as [OpenSubtitles](https://github.com/jellyfin/jellyfin-plugin-opensubtitles))
|
||||
- Customize layout grids for libraries
|
||||
- Access all your favorites quickly from the nav drawer
|
||||
- Multiple app color themes
|
||||
|
||||
### Playback
|
||||
|
|
@ -43,6 +49,8 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin
|
|||
- Optionally skip back a few seconds when resuming playback
|
||||
- Live TV & DVR support
|
||||
- Auto play next episodes with pass out protection
|
||||
- Option for automatic refresh rate switching on supported displays
|
||||
- Trickplay support
|
||||
- Other (subjective) enhancements:
|
||||
- Subtly show playback position along the bottom of the screen while seeking w/ D-Pad
|
||||
- Force Continue Watching & Next Up TV episodes to use their Series posters
|
||||
|
|
|
|||
|
|
@ -262,6 +262,7 @@ dependencies {
|
|||
implementation(libs.androidx.hilt.navigation.compose)
|
||||
implementation(libs.androidx.preference.ktx)
|
||||
implementation(libs.androidx.room.testing)
|
||||
implementation(libs.androidx.palette.ktx)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
ksp(libs.hilt.android.compiler)
|
||||
|
||||
|
|
|
|||
|
|
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -47,6 +47,7 @@ import com.github.damontecres.wholphin.ui.launchIO
|
|||
import com.github.damontecres.wholphin.ui.nav.ApplicationContent
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
|
||||
import com.github.damontecres.wholphin.util.DebugLogTree
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -222,13 +223,15 @@ class MainActivity : AppCompatActivity() {
|
|||
)
|
||||
}
|
||||
}
|
||||
ApplicationContent(
|
||||
user = current?.user,
|
||||
server = current?.server,
|
||||
navigationManager = navigationManager,
|
||||
preferences = preferences,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
ProvideLocalClock {
|
||||
ApplicationContent(
|
||||
user = current?.user,
|
||||
server = current?.server,
|
||||
navigationManager = navigationManager,
|
||||
preferences = preferences,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,10 +9,12 @@ import androidx.room.migration.Migration
|
|||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||
import com.github.damontecres.wholphin.data.model.ItemTrackModification
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
|
||||
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
|
||||
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
|
||||
import com.github.damontecres.wholphin.ui.components.ViewOptions
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
|
|
@ -22,8 +24,16 @@ import timber.log.Timber
|
|||
import java.util.UUID
|
||||
|
||||
@Database(
|
||||
entities = [JellyfinServer::class, JellyfinUser::class, ItemPlayback::class, NavDrawerPinnedItem::class, LibraryDisplayInfo::class],
|
||||
version = 10,
|
||||
entities = [
|
||||
JellyfinServer::class,
|
||||
JellyfinUser::class,
|
||||
ItemPlayback::class,
|
||||
NavDrawerPinnedItem::class,
|
||||
LibraryDisplayInfo::class,
|
||||
PlaybackLanguageChoice::class,
|
||||
ItemTrackModification::class,
|
||||
],
|
||||
version = 12,
|
||||
exportSchema = true,
|
||||
autoMigrations = [
|
||||
AutoMigration(3, 4),
|
||||
|
|
@ -33,6 +43,8 @@ import java.util.UUID
|
|||
AutoMigration(7, 8),
|
||||
AutoMigration(8, 9),
|
||||
AutoMigration(9, 10),
|
||||
AutoMigration(10, 11),
|
||||
AutoMigration(11, 12),
|
||||
],
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
|
|
@ -44,6 +56,8 @@ abstract class AppDatabase : RoomDatabase() {
|
|||
abstract fun serverPreferencesDao(): ServerPreferencesDao
|
||||
|
||||
abstract fun libraryDisplayInfoDao(): LibraryDisplayInfoDao
|
||||
|
||||
abstract fun playbackLanguageChoiceDao(): PlaybackLanguageChoiceDao
|
||||
}
|
||||
|
||||
class Converters {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import androidx.room.Insert
|
|||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||
import com.github.damontecres.wholphin.data.model.ItemTrackModification
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import java.util.UUID
|
||||
|
||||
|
|
@ -26,4 +27,20 @@ interface ItemPlaybackDao {
|
|||
|
||||
@Query("SELECT * from ItemPlayback WHERE userId=:userId")
|
||||
fun getItems(userId: Int): List<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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,14 @@ package com.github.damontecres.wholphin.data
|
|||
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||
import com.github.damontecres.wholphin.data.model.ItemTrackModification
|
||||
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
|
||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||
import com.github.damontecres.wholphin.data.model.chooseSource
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.MediaStream
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
|
|
@ -13,6 +17,7 @@ import timber.log.Timber
|
|||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Duration
|
||||
|
||||
@Singleton
|
||||
class ItemPlaybackRepository
|
||||
|
|
@ -20,47 +25,60 @@ class ItemPlaybackRepository
|
|||
constructor(
|
||||
val serverRepository: ServerRepository,
|
||||
val itemPlaybackDao: ItemPlaybackDao,
|
||||
private val streamChoiceService: StreamChoiceService,
|
||||
) {
|
||||
fun getSelectedTracks(
|
||||
suspend fun getSelectedTracks(
|
||||
itemId: UUID,
|
||||
item: BaseItem,
|
||||
prefs: UserPreferences,
|
||||
): ChosenStreams? =
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId)
|
||||
if (itemPlayback != null) {
|
||||
Timber.v("Got itemPlayback for %s", itemId)
|
||||
getChosenItemFromPlayback(item, itemPlayback)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
||||
Timber.v("For ${item.id}: itemPlayback=${itemPlayback != null}, plc=${plc != null}")
|
||||
return getChosenItemFromPlayback(item, itemPlayback, plc, prefs)
|
||||
}
|
||||
|
||||
fun getChosenItemFromPlayback(
|
||||
item: BaseItem,
|
||||
itemPlayback: ItemPlayback,
|
||||
itemPlayback: ItemPlayback?,
|
||||
plc: PlaybackLanguageChoice?,
|
||||
prefs: UserPreferences,
|
||||
): ChosenStreams? {
|
||||
val source =
|
||||
item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback.sourceId }
|
||||
item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback?.sourceId }
|
||||
?: streamChoiceService.chooseSource(item.data.mediaSources.orEmpty())
|
||||
if (source != null) {
|
||||
val audioStream =
|
||||
if (itemPlayback.audioIndexEnabled) {
|
||||
source.mediaStreams?.firstOrNull { it.index == itemPlayback.audioIndex }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
streamChoiceService.chooseAudioStream(
|
||||
candidates =
|
||||
source.mediaStreams
|
||||
?.filter { it.type == MediaStreamType.AUDIO }
|
||||
.orEmpty(),
|
||||
itemPlayback = itemPlayback,
|
||||
playbackLanguageChoice = plc,
|
||||
prefs = prefs,
|
||||
)
|
||||
val subtitleStream =
|
||||
if (itemPlayback.subtitleIndexEnabled) {
|
||||
source.mediaStreams?.firstOrNull { it.index == itemPlayback.subtitleIndex }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
streamChoiceService.chooseSubtitleStream(
|
||||
audioStream = audioStream,
|
||||
candidates =
|
||||
source.mediaStreams
|
||||
?.filter { it.type == MediaStreamType.SUBTITLE }
|
||||
.orEmpty(),
|
||||
itemPlayback = itemPlayback,
|
||||
playbackLanguageChoice = plc,
|
||||
prefs = prefs,
|
||||
)
|
||||
return ChosenStreams(
|
||||
itemPlayback,
|
||||
item.id,
|
||||
source.id?.toUUIDOrNull(),
|
||||
audioStream,
|
||||
subtitleStream,
|
||||
itemPlayback.subtitleIndex == TrackIndex.DISABLED,
|
||||
itemPlayback = itemPlayback,
|
||||
plc = plc,
|
||||
itemId = item.id,
|
||||
source = source,
|
||||
videoStream = source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO },
|
||||
audioStream = audioStream,
|
||||
subtitleStream = subtitleStream,
|
||||
subtitlesDisabled = itemPlayback?.subtitleIndex == TrackIndex.DISABLED,
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
|
|
@ -89,22 +107,58 @@ class ItemPlaybackRepository
|
|||
itemPlayback: ItemPlayback?,
|
||||
trackIndex: Int,
|
||||
type: MediaStreamType,
|
||||
) = serverRepository.currentUser.value?.let { user ->
|
||||
var toSave =
|
||||
itemPlayback ?: ItemPlayback(
|
||||
userId = user.rowId,
|
||||
itemId = item.id,
|
||||
sourceId = chooseSource(item.data, null)?.id?.toUUIDOrNull(),
|
||||
)
|
||||
toSave =
|
||||
when (type) {
|
||||
MediaStreamType.AUDIO -> toSave.copy(audioIndex = trackIndex)
|
||||
MediaStreamType.SUBTITLE -> toSave.copy(subtitleIndex = trackIndex)
|
||||
else -> toSave
|
||||
): ItemPlayback =
|
||||
serverRepository.current.value!!.let { current ->
|
||||
val source =
|
||||
itemPlayback?.sourceId?.let { sourceId ->
|
||||
item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == sourceId }
|
||||
} ?: streamChoiceService.chooseSource(item.data, null)
|
||||
if (source == null) {
|
||||
Timber.w("Could not find media source for ${item.id}")
|
||||
throw IllegalArgumentException("Could not find media source for ${item.id}")
|
||||
}
|
||||
Timber.v("Saving track selection %s", toSave)
|
||||
saveItemPlayback(toSave)
|
||||
}
|
||||
var toSave =
|
||||
itemPlayback ?: ItemPlayback(
|
||||
userId = current.user.rowId,
|
||||
itemId = item.id,
|
||||
sourceId = source.id?.toUUIDOrNull(),
|
||||
)
|
||||
toSave =
|
||||
when (type) {
|
||||
MediaStreamType.AUDIO -> toSave.copy(audioIndex = trackIndex)
|
||||
MediaStreamType.SUBTITLE -> toSave.copy(subtitleIndex = trackIndex)
|
||||
else -> toSave
|
||||
}
|
||||
Timber.v("Saving track selection %s", toSave)
|
||||
toSave = saveItemPlayback(toSave)
|
||||
val seriesId = item.data.seriesId
|
||||
if (seriesId != null && trackIndex != TrackIndex.UNSPECIFIED) {
|
||||
if (type == MediaStreamType.AUDIO) {
|
||||
val stream = source.mediaStreams?.first { it.index == trackIndex }
|
||||
if (stream?.language != null) {
|
||||
streamChoiceService.updateAudio(item.data, stream.language!!)
|
||||
}
|
||||
} else if (type == MediaStreamType.SUBTITLE) {
|
||||
if (trackIndex == TrackIndex.DISABLED) {
|
||||
streamChoiceService.updateSubtitles(
|
||||
item.data,
|
||||
subtitleLang = null,
|
||||
subtitlesDisabled = true,
|
||||
)
|
||||
} else {
|
||||
val stream = source.mediaStreams?.first { it.index == trackIndex }
|
||||
if (stream?.language != null) {
|
||||
streamChoiceService.updateSubtitles(
|
||||
item.data,
|
||||
stream.language!!,
|
||||
subtitlesDisabled = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
toSave
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the [ItemPlayback] into the database, returning the same object with the rowId updated if needed
|
||||
|
|
@ -124,12 +178,40 @@ class ItemPlaybackRepository
|
|||
val id = itemPlaybackDao.saveItem(toSave)
|
||||
return toSave.copy(rowId = id)
|
||||
}
|
||||
|
||||
suspend fun getTrackModifications(
|
||||
itemId: UUID,
|
||||
trackIndex: Int,
|
||||
): ItemTrackModification? =
|
||||
serverRepository.currentUser.value?.rowId?.let { userId ->
|
||||
itemPlaybackDao.getTrackModifications(userId, itemId, trackIndex)
|
||||
}
|
||||
|
||||
suspend fun saveTrackModifications(
|
||||
itemId: UUID,
|
||||
trackIndex: Int,
|
||||
delay: Duration,
|
||||
) {
|
||||
serverRepository.currentUser.value?.rowId?.let { userId ->
|
||||
Timber.v("Saving track mod item=%s, track=%s, delay=%s", itemId, trackIndex, delay)
|
||||
itemPlaybackDao.saveItem(
|
||||
ItemTrackModification(
|
||||
userId,
|
||||
itemId,
|
||||
trackIndex,
|
||||
delay.inWholeMilliseconds,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ChosenStreams(
|
||||
val itemPlayback: ItemPlayback,
|
||||
val itemPlayback: ItemPlayback?,
|
||||
val plc: PlaybackLanguageChoice?,
|
||||
val itemId: UUID,
|
||||
val sourceId: UUID?,
|
||||
val source: MediaSourceInfo,
|
||||
val videoStream: MediaStream?,
|
||||
val audioStream: MediaStream?,
|
||||
val subtitleStream: MediaStream?,
|
||||
val subtitlesDisabled: Boolean,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -126,7 +126,9 @@ class ServerRepository
|
|||
}
|
||||
if (serverAndUsers != null) {
|
||||
val user = serverAndUsers.users.firstOrNull { it.id == userId }
|
||||
if (user != null && !user.hasPin) {
|
||||
if (user != null) {
|
||||
// TODO pin-related
|
||||
// if (user != null && !user.hasPin) {
|
||||
changeUser(serverAndUsers.server, user)
|
||||
return true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
package com.github.damontecres.wholphin.data.model
|
||||
|
||||
import com.github.damontecres.wholphin.ui.detail.CardGridItem
|
||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
||||
import com.github.damontecres.wholphin.ui.formatDateTime
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.playback.playable
|
||||
import com.github.damontecres.wholphin.ui.seasonEpisode
|
||||
import com.github.damontecres.wholphin.ui.seasonEpisodePadded
|
||||
import com.github.damontecres.wholphin.ui.seriesProductionYears
|
||||
|
|
@ -18,8 +20,14 @@ import kotlin.time.Duration
|
|||
data class BaseItem(
|
||||
val data: BaseItemDto,
|
||||
val useSeriesForPrimary: Boolean,
|
||||
) {
|
||||
val id get() = data.id
|
||||
) : CardGridItem {
|
||||
override val id get() = data.id
|
||||
|
||||
override val playable: Boolean
|
||||
get() = type.playable
|
||||
|
||||
override val sortName: String
|
||||
get() = data.sortName ?: data.name ?: ""
|
||||
|
||||
val type get() = data.type
|
||||
|
||||
|
|
@ -47,6 +55,9 @@ data class BaseItem(
|
|||
}
|
||||
}
|
||||
|
||||
@Transient
|
||||
val aspectRatio: Float? = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 }
|
||||
|
||||
@Transient
|
||||
val indexNumber = data.indexNumber ?: dateAsIndex()
|
||||
|
||||
|
|
|
|||
|
|
@ -7,19 +7,10 @@ import androidx.room.ForeignKey
|
|||
import androidx.room.Ignore
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import kotlinx.serialization.UseSerializers
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.MediaStream
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
|
||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import java.util.UUID
|
||||
|
||||
@Entity(
|
||||
|
|
@ -53,122 +44,6 @@ data class ItemPlayback(
|
|||
val subtitleIndexEnabled = subtitleIndex >= 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the [MediaSourceInfo] with the highest video resolution
|
||||
*/
|
||||
fun chooseSource(sources: List<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 {
|
||||
/**
|
||||
* The user has not explicitly specified a track to use
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -659,6 +659,19 @@ sealed interface AppPreference<Pref, T> {
|
|||
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 =
|
||||
AppSwitchPreference<AppPreferences>(
|
||||
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
|
||||
// AppPreference.NavDrawerSwitchOnFocus,
|
||||
AppPreference.ControllerTimeout,
|
||||
AppPreference.BackdropStylePref,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
@ -948,6 +1007,14 @@ val advancedPreferences =
|
|||
)
|
||||
}
|
||||
|
||||
val liveTvPreferences =
|
||||
listOf(
|
||||
AppPreference.LiveTvShowHeader,
|
||||
AppPreference.LiveTvFavoriteChannelsBeginning,
|
||||
AppPreference.LiveTvChannelSortByWatched,
|
||||
AppPreference.LiveTvColorCodePrograms,
|
||||
)
|
||||
|
||||
data class AppSwitchPreference<Pref>(
|
||||
@get:StringRes override val title: Int,
|
||||
override val defaultValue: Boolean,
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ class AppPreferencesSerializer
|
|||
navDrawerSwitchOnFocus =
|
||||
AppPreference.NavDrawerSwitchOnFocus.defaultValue
|
||||
showClock = AppPreference.ShowClock.defaultValue
|
||||
backdropStyle = AppPreference.BackdropStylePref.defaultValue
|
||||
|
||||
subtitlesPreferences =
|
||||
SubtitlePreferences
|
||||
|
|
@ -98,6 +99,19 @@ class AppPreferencesSerializer
|
|||
.apply {
|
||||
resetSubtitles()
|
||||
}.build()
|
||||
|
||||
liveTvPreferences =
|
||||
LiveTvPreferences
|
||||
.newBuilder()
|
||||
.apply {
|
||||
showHeader = AppPreference.LiveTvShowHeader.defaultValue
|
||||
favoriteChannelsAtBeginning =
|
||||
AppPreference.LiveTvFavoriteChannelsBeginning.defaultValue
|
||||
sortByRecentlyWatched =
|
||||
AppPreference.LiveTvChannelSortByWatched.defaultValue
|
||||
colorCodePrograms =
|
||||
AppPreference.LiveTvColorCodePrograms.defaultValue
|
||||
}.build()
|
||||
}.build()
|
||||
|
||||
advancedPreferences =
|
||||
|
|
@ -155,6 +169,11 @@ inline fun AppPreferences.updateSubtitlePreferences(block: SubtitlePreferences.B
|
|||
subtitlesPreferences = subtitlesPreferences.toBuilder().apply(block).build()
|
||||
}
|
||||
|
||||
inline fun AppPreferences.updateLiveTvPreferences(block: LiveTvPreferences.Builder.() -> Unit): AppPreferences =
|
||||
updateInterfacePreferences {
|
||||
liveTvPreferences = liveTvPreferences.toBuilder().apply(block).build()
|
||||
}
|
||||
|
||||
inline fun AppPreferences.updateAdvancedPreferences(block: AdvancedPreferences.Builder.() -> Unit): AppPreferences =
|
||||
update {
|
||||
advancedPreferences = advancedPreferences.toBuilder().apply(block).build()
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
|
|||
import com.github.damontecres.wholphin.preferences.update
|
||||
import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences
|
||||
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
|
||||
import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences
|
||||
import com.github.damontecres.wholphin.preferences.updateMpvOptions
|
||||
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
|
||||
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
|
||||
|
|
@ -173,4 +174,27 @@ suspend fun upgradeApp(
|
|||
}
|
||||
}
|
||||
}
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.3.5-56-g0"))) {
|
||||
appPreferences.updateData {
|
||||
it.updateLiveTvPreferences {
|
||||
showHeader = AppPreference.LiveTvShowHeader.defaultValue
|
||||
favoriteChannelsAtBeginning =
|
||||
AppPreference.LiveTvFavoriteChannelsBeginning.defaultValue
|
||||
sortByRecentlyWatched =
|
||||
AppPreference.LiveTvChannelSortByWatched.defaultValue
|
||||
colorCodePrograms =
|
||||
AppPreference.LiveTvColorCodePrograms.defaultValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.3.6-52-g0"))) {
|
||||
if (Build.MODEL.equals("shield android tv", ignoreCase = true)) {
|
||||
appPreferences.updateData {
|
||||
it.updateMpvOptions {
|
||||
useGpuNext = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -31,7 +31,7 @@ class ImageUrlService
|
|||
imageType: ImageType,
|
||||
fillWidth: Int? = null,
|
||||
fillHeight: Int? = null,
|
||||
): String? =
|
||||
): String =
|
||||
when (imageType) {
|
||||
ImageType.BACKDROP,
|
||||
ImageType.LOGO,
|
||||
|
|
@ -145,6 +145,8 @@ class ImageUrlService
|
|||
imageIndex = imageIndex,
|
||||
)
|
||||
|
||||
fun getUserImageUrl(userId: UUID) = api.imageApi.getUserImageUrl(userId)
|
||||
|
||||
/**
|
||||
* Just a convenient way to get the image URL and remember it
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -3,9 +3,12 @@ package com.github.damontecres.wholphin.services
|
|||
import android.content.Context
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.Display
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.util.EqualityMutableLiveData
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.delay
|
||||
|
|
@ -29,6 +32,8 @@ class RefreshRateService
|
|||
private val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
|
||||
private val originalMode = display.mode
|
||||
|
||||
val displayModes get() = display.supportedModes
|
||||
|
||||
private val _refreshRateMode = EqualityMutableLiveData<Display.Mode>(originalMode)
|
||||
val refreshRateMode: LiveData<Display.Mode> = _refreshRateMode
|
||||
|
||||
|
|
@ -57,12 +62,18 @@ class RefreshRateService
|
|||
Timber.i("Found display mode: %s, current=${display.mode}", targetMode)
|
||||
if (targetMode != null && targetMode != display.mode) {
|
||||
val listener = Listener(display.displayId)
|
||||
displayManager.registerDisplayListener(listener, null)
|
||||
displayManager.registerDisplayListener(
|
||||
listener,
|
||||
Handler(Looper.myLooper() ?: Looper.getMainLooper()),
|
||||
)
|
||||
_refreshRateMode.setValueOnMain(targetMode)
|
||||
try {
|
||||
listener.latch.await(5, TimeUnit.SECONDS)
|
||||
} catch (_: InterruptedException) {
|
||||
Timber.w("Timed out waiting for display change")
|
||||
if (!listener.latch.await(5, TimeUnit.SECONDS)) {
|
||||
Timber.w("Timed out waiting for display change")
|
||||
showToast(context, "Refresh rate switch is taking a long time")
|
||||
}
|
||||
} catch (ex: InterruptedException) {
|
||||
Timber.w(ex, "Exception waiting for refresh rate switch")
|
||||
}
|
||||
val targetRate = (targetMode.refreshRate * 100).roundToInt()
|
||||
val isSeamless =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
package com.github.damontecres.wholphin.services.hilt
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.provider.Settings
|
||||
import androidx.datastore.core.DataStore
|
||||
import com.github.damontecres.wholphin.BuildConfig
|
||||
import com.github.damontecres.wholphin.R
|
||||
|
|
@ -24,6 +22,7 @@ import kotlinx.coroutines.SupervisorJob
|
|||
import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
import org.jellyfin.sdk.Jellyfin
|
||||
import org.jellyfin.sdk.android.androidDevice
|
||||
import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder
|
||||
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
|
||||
import org.jellyfin.sdk.createJellyfin
|
||||
|
|
@ -61,14 +60,7 @@ object AppModule {
|
|||
@Singleton
|
||||
fun deviceInfo(
|
||||
@ApplicationContext context: Context,
|
||||
): DeviceInfo =
|
||||
DeviceInfo(
|
||||
id = @SuppressLint("HardwareIds") Settings.Secure.getString(
|
||||
context.contentResolver,
|
||||
Settings.Secure.ANDROID_ID,
|
||||
),
|
||||
name = Settings.Global.getString(context.contentResolver, Settings.Global.DEVICE_NAME),
|
||||
)
|
||||
): DeviceInfo = androidDevice(context)
|
||||
|
||||
@StandardOkHttpClient
|
||||
@Provides
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.data.ItemPlaybackDao
|
|||
import com.github.damontecres.wholphin.data.JellyfinServerDao
|
||||
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
|
||||
import com.github.damontecres.wholphin.data.Migrations
|
||||
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
|
||||
import com.github.damontecres.wholphin.data.ServerPreferencesDao
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
|
||||
|
|
@ -56,6 +57,10 @@ object DatabaseModule {
|
|||
@Singleton
|
||||
fun libraryDisplayInfoDao(db: AppDatabase): LibraryDisplayInfoDao = db.libraryDisplayInfoDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun playbackLanguageChoiceDao(db: AppDatabase): PlaybackLanguageChoiceDao = db.playbackLanguageChoiceDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun userPreferencesDataStore(
|
||||
|
|
|
|||
|
|
@ -8,8 +8,12 @@ import coil3.compose.setSingletonImageLoaderFactory
|
|||
import coil3.disk.DiskCache
|
||||
import coil3.disk.directory
|
||||
import coil3.memory.MemoryCache
|
||||
import coil3.network.CacheStrategy
|
||||
import coil3.network.NetworkRequest
|
||||
import coil3.network.NetworkResponse
|
||||
import coil3.network.cachecontrol.CacheControlCacheStrategy
|
||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import coil3.request.Options
|
||||
import coil3.request.crossfade
|
||||
import coil3.util.DebugLogger
|
||||
import okhttp3.OkHttpClient
|
||||
|
|
@ -67,10 +71,39 @@ fun CoilConfig(
|
|||
.components {
|
||||
add(
|
||||
OkHttpNetworkFetcherFactory(
|
||||
cacheStrategy = { CacheControlCacheStrategy() },
|
||||
cacheStrategy = { WholphinCacheStrategy(CacheControlCacheStrategy()) },
|
||||
callFactory = { client },
|
||||
),
|
||||
)
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This [CacheStrategy] always prefers the cached response for Trickplay images,
|
||||
* otherwise the decision is delegated to the provided [CacheStrategy]
|
||||
*
|
||||
* The expectation is that Trickplay images will be prefetched so the cache will always be warm
|
||||
*/
|
||||
@OptIn(ExperimentalCoilApi::class)
|
||||
private class WholphinCacheStrategy(
|
||||
private val delegate: CacheStrategy,
|
||||
) : CacheStrategy {
|
||||
override suspend fun read(
|
||||
cacheResponse: NetworkResponse,
|
||||
networkRequest: NetworkRequest,
|
||||
options: Options,
|
||||
): CacheStrategy.ReadResult =
|
||||
if (networkRequest.url.contains("/Trickplay/")) {
|
||||
CacheStrategy.ReadResult(cacheResponse)
|
||||
} else {
|
||||
delegate.read(cacheResponse, networkRequest, options)
|
||||
}
|
||||
|
||||
override suspend fun write(
|
||||
cacheResponse: NetworkResponse?,
|
||||
networkRequest: NetworkRequest,
|
||||
networkResponse: NetworkResponse,
|
||||
options: Options,
|
||||
): CacheStrategy.WriteResult = delegate.write(cacheResponse, networkRequest, networkResponse, options)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ val SlimItemFields =
|
|||
object Cards {
|
||||
val height2x3 = 172.dp
|
||||
val playedPercentHeight = 6.dp
|
||||
val serverUserCircle = height2x3 * .75f
|
||||
}
|
||||
|
||||
object AspectRatios {
|
||||
|
|
@ -75,6 +76,8 @@ object AspectRatios {
|
|||
const val FOUR_THREE = 4f / 3f
|
||||
const val TALL = 2f / 3f
|
||||
const val SQUARE = 1f
|
||||
|
||||
const val MIN = TALL
|
||||
}
|
||||
|
||||
enum class AspectRatio(
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ fun EpisodeCard(
|
|||
} else {
|
||||
focusedAfterDelay = false
|
||||
}
|
||||
val aspectRatio = dto?.primaryImageAspectRatio?.toFloat() ?: AspectRatios.TALL
|
||||
val aspectRatio = item?.aspectRatio?.coerceAtLeast(AspectRatios.MIN) ?: AspectRatios.MIN
|
||||
val width = imageHeight * aspectRatio
|
||||
val height = imageWidth * (1f / aspectRatio)
|
||||
Column(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
package com.github.damontecres.wholphin.ui.cards
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
|
|
@ -44,6 +45,7 @@ fun GridCard(
|
|||
imageAspectRatio: Float = AspectRatios.TALL,
|
||||
imageContentScale: ContentScale = ContentScale.Fit,
|
||||
imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
|
||||
showTitle: Boolean = true,
|
||||
) {
|
||||
val dto = item?.data
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
|
|
@ -94,38 +96,40 @@ fun GridCard(
|
|||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(imageAspectRatio)
|
||||
.aspectRatio(imageAspectRatio.coerceAtLeast(AspectRatios.MIN))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
)
|
||||
}
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(bottom = spaceBelow)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = item?.title ?: "",
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
AnimatedVisibility(showTitle) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp)
|
||||
.enableMarquee(focusedAfterDelay),
|
||||
)
|
||||
Text(
|
||||
text = item?.subtitle ?: "",
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp)
|
||||
.enableMarquee(focusedAfterDelay),
|
||||
)
|
||||
.padding(bottom = spaceBelow)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = item?.title ?: "",
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp)
|
||||
.enableMarquee(focusedAfterDelay),
|
||||
)
|
||||
Text(
|
||||
text = item?.subtitle ?: "",
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp)
|
||||
.enableMarquee(focusedAfterDelay),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,8 +113,7 @@ fun BannerItemRow(
|
|||
name = title,
|
||||
item = item,
|
||||
aspectRatio =
|
||||
aspectRatioOverride ?: item?.data?.primaryImageAspectRatio?.toFloat()
|
||||
?: AspectRatios.WIDE,
|
||||
aspectRatioOverride ?: item?.aspectRatio ?: AspectRatios.WIDE,
|
||||
cornerText = item?.data?.indexNumber?.let { "E$it" },
|
||||
played = item?.data?.userData?.played ?: false,
|
||||
playPercent = item?.data?.userData?.playedPercentage ?: 0.0,
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ fun SeasonCard(
|
|||
imageWidth: Dp = Dp.Unspecified,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
showImageOverlay: Boolean = false,
|
||||
aspectRatio: Float = item?.data?.primaryImageAspectRatio?.toFloat() ?: AspectRatios.TALL,
|
||||
aspectRatio: Float = item?.aspectRatio ?: AspectRatios.TALL,
|
||||
) {
|
||||
val imageUrlService = LocalImageUrlService.current
|
||||
val density = LocalDensity.current
|
||||
|
|
@ -139,8 +139,9 @@ fun SeasonCard(
|
|||
} else {
|
||||
focusedAfterDelay = false
|
||||
}
|
||||
val width = imageHeight * aspectRatio
|
||||
val height = imageWidth * (1f / aspectRatio)
|
||||
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
|
||||
val width = imageHeight * aspectRationToUse
|
||||
val height = imageWidth * (1f / aspectRationToUse)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(spaceBetween),
|
||||
modifier = modifier.size(width, height),
|
||||
|
|
@ -149,7 +150,7 @@ fun SeasonCard(
|
|||
modifier =
|
||||
Modifier
|
||||
.size(imageWidth, imageHeight)
|
||||
.aspectRatio(aspectRatio),
|
||||
.aspectRatio(aspectRationToUse),
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
interactionSource = interactionSource,
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
|||
import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride
|
||||
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||
|
|
@ -76,6 +77,7 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
|
|||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.main.HomePageHeader
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.playback.scale
|
||||
|
|
@ -123,6 +125,7 @@ class CollectionFolderViewModel
|
|||
private val serverRepository: ServerRepository,
|
||||
private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
|
||||
private val favoriteWatchManager: FavoriteWatchManager,
|
||||
private val backdropService: BackdropService,
|
||||
val navigationManager: NavigationManager,
|
||||
) : ItemViewModel(api) {
|
||||
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
||||
|
|
@ -202,6 +205,9 @@ class CollectionFolderViewModel
|
|||
this.viewOptions.value = viewOptions
|
||||
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||
saveLibraryDisplayInfo(viewOptions = viewOptions)
|
||||
if (!viewOptions.showDetails) {
|
||||
backdropService.clearBackdrop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -246,13 +252,26 @@ class CollectionFolderViewModel
|
|||
this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection
|
||||
this@CollectionFolderViewModel.filter.value = filter
|
||||
}
|
||||
val newPager = createPager(sortAndDirection, recursive, filter, useSeriesForPrimary)
|
||||
newPager.init()
|
||||
if (newPager.isNotEmpty()) newPager.getBlocking(0)
|
||||
withContext(Dispatchers.Main) {
|
||||
pager.value = newPager
|
||||
loading.value = LoadingState.Success
|
||||
backgroundLoading.value = LoadingState.Success
|
||||
try {
|
||||
val newPager =
|
||||
createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init()
|
||||
if (newPager.isNotEmpty()) newPager.getBlocking(0)
|
||||
withContext(Dispatchers.Main) {
|
||||
pager.value = newPager
|
||||
loading.value = LoadingState.Success
|
||||
backgroundLoading.value = LoadingState.Success
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(
|
||||
ex,
|
||||
"Exception while loading data: sort=%s, filter=%s",
|
||||
sortAndDirection,
|
||||
filter,
|
||||
)
|
||||
withContext(Dispatchers.Main) {
|
||||
loading.value = LoadingState.Error(ex)
|
||||
pager.value = listOf()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -454,6 +473,12 @@ class CollectionFolderViewModel
|
|||
favoriteWatchManager.setFavorite(itemId, favorite)
|
||||
(pager.value as? ApiRequestPager<*>)?.refreshItem(position, itemId)
|
||||
}
|
||||
|
||||
fun updateBackdrop(item: BaseItem) {
|
||||
viewModelScope.launchIO {
|
||||
backdropService.submit(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -505,7 +530,7 @@ fun CollectionFolderGrid(
|
|||
playEnabled: Boolean,
|
||||
defaultViewOptions: ViewOptions,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: CollectionFolderViewModel = hiltViewModel(),
|
||||
viewModel: CollectionFolderViewModel = hiltViewModel(key = itemId),
|
||||
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||
initialSortAndDirection: SortAndDirection? = null,
|
||||
showTitle: Boolean = true,
|
||||
|
|
@ -578,8 +603,12 @@ fun CollectionFolderGrid(
|
|||
viewOptions = viewOptions,
|
||||
defaultViewOptions = defaultViewOptions,
|
||||
onSaveViewOptions = { viewModel.saveViewOptions(it) },
|
||||
onChangeBackdrop = viewModel::updateBackdrop,
|
||||
playEnabled = playEnabled,
|
||||
onClickPlay = { shuffle ->
|
||||
onClickPlay = { _, item ->
|
||||
viewModel.navigationManager.navigateTo(Destination.Playback(item))
|
||||
},
|
||||
onClickPlayAll = { shuffle ->
|
||||
itemId.toUUIDOrNull()?.let {
|
||||
viewModel.navigationManager.navigateTo(
|
||||
Destination.PlaybackList(
|
||||
|
|
@ -678,17 +707,19 @@ fun CollectionFolderGridContent(
|
|||
letterPosition: suspend (Char) -> Int,
|
||||
sortOptions: List<ItemSortBy>,
|
||||
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,
|
||||
showTitle: Boolean = true,
|
||||
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
||||
currentFilter: GetItemsFilter = GetItemsFilter(),
|
||||
filterOptions: List<ItemFilterBy<*>> = listOf(),
|
||||
onFilterChange: (GetItemsFilter) -> Unit = {},
|
||||
getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>,
|
||||
defaultViewOptions: ViewOptions,
|
||||
viewOptions: ViewOptions,
|
||||
onSaveViewOptions: (ViewOptions) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val title = item?.name ?: item?.data?.collectionType?.name ?: stringResource(R.string.collection)
|
||||
|
|
@ -703,14 +734,13 @@ fun CollectionFolderGridContent(
|
|||
|
||||
var position by rememberInt(0)
|
||||
val focusedItem = pager.getOrNull(position)
|
||||
if (viewOptions.showDetails) {
|
||||
LaunchedEffect(focusedItem) {
|
||||
focusedItem?.let(onChangeBackdrop)
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = modifier) {
|
||||
if (viewOptions.showDetails) {
|
||||
DelayedDetailsBackdropImage(
|
||||
item = focusedItem,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
|
|
@ -786,12 +816,12 @@ fun CollectionFolderGridContent(
|
|||
title = R.string.play,
|
||||
resume = Duration.ZERO,
|
||||
icon = Icons.Default.PlayArrow,
|
||||
onClick = { onClickPlay.invoke(false) },
|
||||
onClick = { onClickPlayAll.invoke(false) },
|
||||
)
|
||||
ExpandableFaButton(
|
||||
title = R.string.shuffle,
|
||||
iconStringRes = R.string.fa_shuffle,
|
||||
onClick = { onClickPlay.invoke(true) },
|
||||
onClick = { onClickPlayAll.invoke(true) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -813,6 +843,7 @@ fun CollectionFolderGridContent(
|
|||
pager = pager,
|
||||
onClickItem = onClickItem,
|
||||
onLongClickItem = onLongClickItem,
|
||||
onClickPlay = onClickPlay,
|
||||
letterPosition = letterPosition,
|
||||
gridFocusRequester = gridFocusRequester,
|
||||
showJumpButtons = false, // TODO add preference
|
||||
|
|
@ -832,6 +863,7 @@ fun CollectionFolderGridContent(
|
|||
imageContentScale = viewOptions.contentScale.scale,
|
||||
imageAspectRatio = viewOptions.aspectRatio.ratio,
|
||||
imageType = viewOptions.imageType,
|
||||
showTitle = viewOptions.showTitles,
|
||||
modifier = mod,
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -310,6 +310,9 @@ fun DialogPopup(
|
|||
@Composable
|
||||
fun ScrollableDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
width: Dp = 600.dp,
|
||||
maxHeight: Dp = 380.dp,
|
||||
itemSpacing: Dp = 8.dp,
|
||||
content: LazyListScope.() -> Unit,
|
||||
) {
|
||||
val scrollAmount = 100f
|
||||
|
|
@ -331,12 +334,12 @@ fun ScrollableDialog(
|
|||
LazyColumn(
|
||||
state = columnState,
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(itemSpacing),
|
||||
content = content,
|
||||
modifier =
|
||||
Modifier
|
||||
.width(600.dp)
|
||||
.heightIn(max = 380.dp)
|
||||
.width(width)
|
||||
.heightIn(max = maxHeight)
|
||||
.focusable()
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
|
||||
|
|
|
|||
|
|
@ -5,33 +5,43 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
|
|||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicSecureTextField
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.input.KeyboardActionHandler
|
||||
import androidx.compose.foundation.text.input.TextFieldDecorator
|
||||
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.TextFieldDefaults.Container
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.LocalContentColor
|
||||
|
|
@ -39,6 +49,7 @@ import androidx.tv.material3.MaterialTheme
|
|||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
import com.google.protobuf.value
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
|
|
@ -191,6 +202,159 @@ fun SearchEditTextBox(
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A modified [BasicTextField] that looks & fits better with TV material controls
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EditTextBox(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
||||
leadingIcon: @Composable (() -> Unit)? = null,
|
||||
enabled: Boolean = true,
|
||||
readOnly: Boolean = false,
|
||||
height: Dp = 40.dp,
|
||||
isInputValid: (String) -> Boolean = { true },
|
||||
supportingText: @Composable (() -> Unit)? = null,
|
||||
placeholder: @Composable (() -> Unit)? = null,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
val colors =
|
||||
TextFieldDefaults.colors(
|
||||
unfocusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
|
||||
focusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f),
|
||||
disabledTextColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.25f),
|
||||
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.25f),
|
||||
errorContainerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.75f),
|
||||
errorTextColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) {
|
||||
BasicTextField(
|
||||
value = value,
|
||||
modifier =
|
||||
modifier
|
||||
.defaultMinSize(
|
||||
minWidth = TextFieldDefaults.MinWidth,
|
||||
minHeight = height,
|
||||
).height(height),
|
||||
onValueChange = onValueChange,
|
||||
enabled = enabled,
|
||||
readOnly = readOnly,
|
||||
textStyle = MaterialTheme.typography.bodyLarge.merge(MaterialTheme.colorScheme.onPrimaryContainer),
|
||||
cursorBrush = SolidColor(colors.cursorColor),
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = keyboardActions,
|
||||
interactionSource = interactionSource,
|
||||
singleLine = true,
|
||||
maxLines = 1,
|
||||
minLines = 1,
|
||||
visualTransformation =
|
||||
if (keyboardOptions.keyboardType == KeyboardType.Password ||
|
||||
keyboardOptions.keyboardType == KeyboardType.NumberPassword
|
||||
) {
|
||||
PasswordVisualTransformation()
|
||||
} else {
|
||||
VisualTransformation.None
|
||||
},
|
||||
decorationBox =
|
||||
@Composable { innerTextField ->
|
||||
// places leading icon, text field with label and placeholder, trailing icon
|
||||
TextFieldDefaults.DecorationBox(
|
||||
value = value,
|
||||
visualTransformation =
|
||||
if (keyboardOptions.keyboardType == KeyboardType.Password ||
|
||||
keyboardOptions.keyboardType == KeyboardType.NumberPassword
|
||||
) {
|
||||
PasswordVisualTransformation()
|
||||
} else {
|
||||
VisualTransformation.None
|
||||
},
|
||||
innerTextField = innerTextField,
|
||||
placeholder = placeholder,
|
||||
label = null,
|
||||
leadingIcon = leadingIcon,
|
||||
trailingIcon = null,
|
||||
prefix = null,
|
||||
suffix = null,
|
||||
supportingText = supportingText,
|
||||
shape = CircleShape,
|
||||
singleLine = true,
|
||||
enabled = enabled,
|
||||
isError = false,
|
||||
interactionSource = interactionSource,
|
||||
colors = colors,
|
||||
contentPadding =
|
||||
PaddingValues(
|
||||
horizontal = 8.dp,
|
||||
vertical = 10.dp,
|
||||
),
|
||||
container = {
|
||||
Container(
|
||||
enabled = enabled,
|
||||
isError = !isInputValid.invoke(value),
|
||||
interactionSource = interactionSource,
|
||||
modifier = Modifier,
|
||||
colors = colors,
|
||||
shape = CircleShape,
|
||||
focusedIndicatorLineThickness = 4.dp,
|
||||
unfocusedIndicatorLineThickness = 0.dp,
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* And [EditTextBox] styles for searches
|
||||
*/
|
||||
@Composable
|
||||
fun SearchEditTextBox(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
onSearchClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
readOnly: Boolean = false,
|
||||
height: Dp = 40.dp,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
EditTextBox(
|
||||
value,
|
||||
onValueChange,
|
||||
modifier,
|
||||
keyboardOptions =
|
||||
KeyboardOptions(
|
||||
autoCorrectEnabled = false,
|
||||
imeAction = ImeAction.Search,
|
||||
),
|
||||
keyboardActions =
|
||||
KeyboardActions(
|
||||
onSearch = {
|
||||
onSearchClick.invoke()
|
||||
this.defaultKeyboardAction(ImeAction.Search)
|
||||
},
|
||||
),
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = stringResource(R.string.search),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
},
|
||||
enabled = enabled,
|
||||
readOnly = readOnly,
|
||||
height = height,
|
||||
interactionSource = interactionSource,
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewTvSpec
|
||||
@Composable
|
||||
private fun EditTextBoxPreview() {
|
||||
|
|
@ -199,6 +363,10 @@ private fun EditTextBoxPreview() {
|
|||
EditTextBox(
|
||||
state = rememberTextFieldState("string"),
|
||||
)
|
||||
EditTextBox(
|
||||
value = "string",
|
||||
onValueChange = {},
|
||||
)
|
||||
SearchEditTextBox(
|
||||
state = rememberTextFieldState("search query"),
|
||||
onSearchClick = { },
|
||||
|
|
|
|||
|
|
@ -10,30 +10,43 @@ import androidx.compose.runtime.livedata.observeAsState
|
|||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
import com.github.damontecres.wholphin.ui.cards.GridCard
|
||||
import com.github.damontecres.wholphin.ui.cards.GenreCard
|
||||
import com.github.damontecres.wholphin.ui.detail.CardGrid
|
||||
import com.github.damontecres.wholphin.ui.detail.CardGridItem
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.request.GetGenresRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
|
|
@ -41,11 +54,12 @@ class GenreViewModel
|
|||
@Inject
|
||||
constructor(
|
||||
private val api: ApiClient,
|
||||
private val imageUrlService: ImageUrlService,
|
||||
val navigationManager: NavigationManager,
|
||||
) : ViewModel() {
|
||||
private lateinit var itemId: UUID
|
||||
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||
val genres = MutableLiveData<List<BaseItem?>>(listOf())
|
||||
val genres = MutableLiveData<List<Genre>>(listOf())
|
||||
|
||||
fun init(itemId: UUID) {
|
||||
loading.value = LoadingState.Loading
|
||||
|
|
@ -56,11 +70,69 @@ class GenreViewModel
|
|||
parentId = itemId,
|
||||
fields = SlimItemFields,
|
||||
)
|
||||
val pager = ApiRequestPager(api, request, GetGenresRequestHandler, viewModelScope).init()
|
||||
val genres =
|
||||
GetGenresRequestHandler
|
||||
.execute(api, request)
|
||||
.content.items
|
||||
.map {
|
||||
Genre(it.id, it.name ?: "", null, Color.Black)
|
||||
}
|
||||
// val pager = ApiRequestPager(api, request, GetGenresRequestHandler, viewModelScope).init()
|
||||
withContext(Dispatchers.Main) {
|
||||
genres.value = pager
|
||||
this@GenreViewModel.genres.value = genres
|
||||
loading.value = LoadingState.Success
|
||||
}
|
||||
// val excludeItemIds = mutableSetOf<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
|
||||
fun GenreCardGrid(
|
||||
itemId: UUID,
|
||||
|
|
@ -117,6 +199,7 @@ fun GenreCardGrid(
|
|||
)
|
||||
},
|
||||
onLongClickItem = { _, _ -> },
|
||||
onClickPlay = { _, _ -> },
|
||||
letterPosition = { viewModel.positionOfLetter(it) },
|
||||
gridFocusRequester = gridFocusRequester,
|
||||
showJumpButtons = false,
|
||||
|
|
@ -125,13 +208,13 @@ fun GenreCardGrid(
|
|||
initialPosition = 0,
|
||||
positionCallback = { columns, position ->
|
||||
},
|
||||
cardContent = { item: BaseItem?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier ->
|
||||
GridCard(
|
||||
item = item,
|
||||
columns = 4,
|
||||
cardContent = { item: Genre?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier ->
|
||||
GenreCard(
|
||||
genre = item,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = mod,
|
||||
imageAspectRatio = 1f,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@ fun ItemGrid(
|
|||
viewModel.navigateTo(Destination.Playback(item.id, 0))
|
||||
},
|
||||
onLongClickItem = { index: Int, item: BaseItem -> },
|
||||
onClickPlay = { _, item -> viewModel.navigateTo(Destination.Playback(item)) },
|
||||
letterPosition = { c: Char -> 0 },
|
||||
gridFocusRequester = focusRequester,
|
||||
showJumpButtons = false,
|
||||
|
|
|
|||
|
|
@ -1,29 +1,32 @@
|
|||
package com.github.damontecres.wholphin.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.ui.TimeFormatter
|
||||
import com.github.damontecres.wholphin.ui.roundMinutes
|
||||
import com.github.damontecres.wholphin.ui.timeRemaining
|
||||
import com.github.damontecres.wholphin.ui.util.LocalClock
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Composable
|
||||
fun MovieQuickDetails(
|
||||
dto: BaseItemDto?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val now = LocalClock.current.now
|
||||
val details =
|
||||
remember(dto) {
|
||||
remember(dto, now) {
|
||||
buildList {
|
||||
dto?.productionYear?.let { add(it.toString()) }
|
||||
dto?.runTimeTicks?.ticks?.roundMinutes?.let {
|
||||
add(it.toString())
|
||||
}
|
||||
dto?.timeRemaining?.roundMinutes?.let {
|
||||
add("$it left")
|
||||
}
|
||||
addRuntimeDetails(context, now, dto)
|
||||
dto?.officialRating?.let(::add)
|
||||
}
|
||||
}
|
||||
|
|
@ -36,3 +39,21 @@ fun MovieQuickDetails(
|
|||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
fun MutableList<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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,39 +78,37 @@ fun ExpandablePlayButtons(
|
|||
if (resumePosition > Duration.ZERO) {
|
||||
item("play") {
|
||||
ExpandablePlayButton(
|
||||
R.string.resume,
|
||||
resumePosition,
|
||||
Icons.Default.PlayArrow,
|
||||
playOnClick,
|
||||
Modifier
|
||||
.onFocusChanged(buttonOnFocusChanged)
|
||||
.focusRequester(firstFocus)
|
||||
.animateItem(),
|
||||
title = R.string.resume,
|
||||
resume = resumePosition,
|
||||
icon = Icons.Default.PlayArrow,
|
||||
onClick = playOnClick,
|
||||
modifier =
|
||||
Modifier
|
||||
.onFocusChanged(buttonOnFocusChanged)
|
||||
.focusRequester(firstFocus),
|
||||
)
|
||||
}
|
||||
item("restart") {
|
||||
ExpandablePlayButton(
|
||||
R.string.restart,
|
||||
Duration.ZERO,
|
||||
Icons.Default.Refresh,
|
||||
playOnClick,
|
||||
Modifier
|
||||
.onFocusChanged(buttonOnFocusChanged)
|
||||
.animateItem(),
|
||||
title = R.string.restart,
|
||||
resume = Duration.ZERO,
|
||||
icon = Icons.Default.Refresh,
|
||||
onClick = playOnClick,
|
||||
modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
|
||||
mirrorIcon = true,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
item("play") {
|
||||
ExpandablePlayButton(
|
||||
R.string.play,
|
||||
Duration.ZERO,
|
||||
Icons.Default.PlayArrow,
|
||||
playOnClick,
|
||||
Modifier
|
||||
.onFocusChanged(buttonOnFocusChanged)
|
||||
.focusRequester(firstFocus)
|
||||
.animateItem(),
|
||||
title = R.string.play,
|
||||
resume = Duration.ZERO,
|
||||
icon = Icons.Default.PlayArrow,
|
||||
onClick = playOnClick,
|
||||
modifier =
|
||||
Modifier
|
||||
.onFocusChanged(buttonOnFocusChanged)
|
||||
.focusRequester(firstFocus),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -121,10 +119,7 @@ fun ExpandablePlayButtons(
|
|||
title = if (watched) R.string.mark_unwatched else R.string.mark_watched,
|
||||
iconStringRes = if (watched) R.string.fa_eye else R.string.fa_eye_slash,
|
||||
onClick = watchOnClick,
|
||||
modifier =
|
||||
Modifier
|
||||
.onFocusChanged(buttonOnFocusChanged)
|
||||
.animateItem(),
|
||||
modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -135,23 +130,18 @@ fun ExpandablePlayButtons(
|
|||
iconStringRes = R.string.fa_heart,
|
||||
onClick = favoriteOnClick,
|
||||
iconColor = if (favorite) Color.Red else Color.Unspecified,
|
||||
modifier =
|
||||
Modifier
|
||||
.onFocusChanged(buttonOnFocusChanged)
|
||||
.animateItem(),
|
||||
modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
|
||||
)
|
||||
}
|
||||
|
||||
// More button
|
||||
item("more") {
|
||||
ExpandablePlayButton(
|
||||
R.string.more,
|
||||
Duration.ZERO,
|
||||
Icons.Default.MoreVert,
|
||||
{ moreOnClick.invoke() },
|
||||
Modifier
|
||||
.onFocusChanged(buttonOnFocusChanged)
|
||||
.animateItem(),
|
||||
title = R.string.more,
|
||||
resume = Duration.ZERO,
|
||||
icon = Icons.Default.MoreVert,
|
||||
onClick = { moreOnClick.invoke() },
|
||||
modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import androidx.lifecycle.viewModelScope
|
|||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
||||
|
|
@ -31,6 +32,7 @@ import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
|||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.main.HomePageContent
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
|
|
@ -44,6 +46,7 @@ abstract class RecommendedViewModel(
|
|||
val context: Context,
|
||||
val navigationManager: NavigationManager,
|
||||
val favoriteWatchManager: FavoriteWatchManager,
|
||||
private val backdropService: BackdropService,
|
||||
) : ViewModel() {
|
||||
abstract fun init()
|
||||
|
||||
|
|
@ -85,6 +88,12 @@ abstract class RecommendedViewModel(
|
|||
}
|
||||
}
|
||||
|
||||
fun updateBackdrop(item: BaseItem) {
|
||||
viewModelScope.launchIO {
|
||||
backdropService.submit(item)
|
||||
}
|
||||
}
|
||||
|
||||
abstract fun update(
|
||||
@StringRes title: Int,
|
||||
row: HomeRowLoadingState,
|
||||
|
|
@ -146,8 +155,12 @@ fun RecommendedContent(
|
|||
onLongClickItem = { position, item ->
|
||||
moreDialog.makePresent(RowColumnItem(position, item))
|
||||
},
|
||||
onClickPlay = { _, item ->
|
||||
viewModel.navigationManager.navigateTo(Destination.Playback(item))
|
||||
},
|
||||
onFocusPosition = onFocusPosition,
|
||||
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
||||
onUpdateBackdrop = viewModel::updateBackdrop,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.data.ServerRepository
|
|||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
|
|
@ -56,7 +57,8 @@ class RecommendedMovieViewModel
|
|||
@Assisted val parentId: UUID,
|
||||
navigationManager: NavigationManager,
|
||||
favoriteWatchManager: FavoriteWatchManager,
|
||||
) : RecommendedViewModel(context, navigationManager, favoriteWatchManager) {
|
||||
backdropService: BackdropService,
|
||||
) : RecommendedViewModel(context, navigationManager, favoriteWatchManager, backdropService) {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(parentId: UUID): RecommendedMovieViewModel
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.data.ServerRepository
|
|||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
|
|
@ -59,7 +60,8 @@ class RecommendedTvShowViewModel
|
|||
@Assisted val parentId: UUID,
|
||||
navigationManager: NavigationManager,
|
||||
favoriteWatchManager: FavoriteWatchManager,
|
||||
) : RecommendedViewModel(context, navigationManager, favoriteWatchManager) {
|
||||
backdropService: BackdropService,
|
||||
) : RecommendedViewModel(context, navigationManager, favoriteWatchManager, backdropService) {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(parentId: UUID): RecommendedTvShowViewModel
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ package com.github.damontecres.wholphin.ui.components
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
|
|
@ -10,7 +12,7 @@ import com.github.damontecres.wholphin.ui.formatDateTime
|
|||
import com.github.damontecres.wholphin.ui.roundMinutes
|
||||
import com.github.damontecres.wholphin.ui.seasonEpisode
|
||||
import com.github.damontecres.wholphin.ui.seriesProductionYears
|
||||
import com.github.damontecres.wholphin.ui.timeRemaining
|
||||
import com.github.damontecres.wholphin.ui.util.LocalClock
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
|
||||
|
|
@ -23,6 +25,7 @@ fun SeriesName(
|
|||
text = seriesName ?: "",
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier,
|
||||
|
|
@ -55,17 +58,14 @@ fun EpisodeQuickDetails(
|
|||
dto: BaseItemDto?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val now = LocalClock.current.now
|
||||
val details =
|
||||
remember(dto) {
|
||||
remember(dto, now) {
|
||||
buildList {
|
||||
dto?.seasonEpisode?.let(::add)
|
||||
dto?.premiereDate?.let { add(formatDateTime(it)) }
|
||||
val duration = dto?.runTimeTicks?.ticks
|
||||
duration
|
||||
?.roundMinutes
|
||||
?.toString()
|
||||
?.let(::add)
|
||||
dto?.timeRemaining?.roundMinutes?.let { add("$it left") }
|
||||
addRuntimeDetails(context, now, dto)
|
||||
dto?.officialRating?.let(::add)
|
||||
}
|
||||
}
|
||||
|
|
@ -87,11 +87,9 @@ fun SeriesQuickDetails(
|
|||
remember(dto) {
|
||||
buildList {
|
||||
dto?.seriesProductionYears?.let(::add)
|
||||
val duration = dto?.runTimeTicks?.ticks
|
||||
duration
|
||||
?.roundMinutes
|
||||
?.toString()
|
||||
?.let(::add)
|
||||
dto?.runTimeTicks?.ticks?.roundMinutes?.let {
|
||||
add(it.toString())
|
||||
}
|
||||
dto?.officialRating?.let(::add)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,35 +3,21 @@ package com.github.damontecres.wholphin.ui.components
|
|||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.ui.TimeFormatter
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import java.time.LocalTime
|
||||
import com.github.damontecres.wholphin.ui.util.LocalClock
|
||||
|
||||
@Composable
|
||||
fun BoxScope.TimeDisplay(modifier: Modifier = Modifier) {
|
||||
var now by remember { mutableStateOf(LocalTime.now()) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (isActive) {
|
||||
now = LocalTime.now()
|
||||
delay(1000L)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = TimeFormatter.format(now),
|
||||
text = LocalClock.current.timeString,
|
||||
fontSize = 18.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier =
|
||||
modifier
|
||||
.align(Alignment.TopEnd)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,11 @@ import androidx.compose.foundation.layout.padding
|
|||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.NonRestartableComposable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
|
@ -23,30 +27,41 @@ import androidx.tv.material3.MaterialTheme
|
|||
import androidx.tv.material3.ProvideTextStyle
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||
import com.github.damontecres.wholphin.data.model.chooseSource
|
||||
import com.github.damontecres.wholphin.data.model.chooseStream
|
||||
import com.github.damontecres.wholphin.data.ChosenStreams
|
||||
import com.github.damontecres.wholphin.preferences.AppThemeColors
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.playback.audioStreamCount
|
||||
import com.github.damontecres.wholphin.ui.playback.embeddedSubtitleCount
|
||||
import com.github.damontecres.wholphin.ui.playback.externalSubtitlesCount
|
||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
import com.github.damontecres.wholphin.util.languageName
|
||||
import com.github.damontecres.wholphin.util.profile.Codec
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.MediaStream
|
||||
import org.jellyfin.sdk.model.api.VideoRange
|
||||
import org.jellyfin.sdk.model.api.VideoRangeType
|
||||
|
||||
@Composable
|
||||
@NonRestartableComposable
|
||||
fun VideoStreamDetails(
|
||||
preferences: UserPreferences,
|
||||
dto: BaseItemDto,
|
||||
itemPlayback: ItemPlayback?,
|
||||
chosenStreams: ChosenStreams?,
|
||||
modifier: Modifier = Modifier,
|
||||
) = VideoStreamDetails(
|
||||
chosenStreams?.source,
|
||||
chosenStreams?.videoStream,
|
||||
chosenStreams?.audioStream,
|
||||
chosenStreams?.subtitleStream,
|
||||
modifier,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun VideoStreamDetails(
|
||||
source: MediaSourceInfo?,
|
||||
videoStream: MediaStream?,
|
||||
audioStream: MediaStream?,
|
||||
subtitleStream: MediaStream?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
|
@ -54,17 +69,6 @@ fun VideoStreamDetails(
|
|||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
val source = remember(dto, itemPlayback) { chooseSource(dto, itemPlayback) }
|
||||
|
||||
val videoStream =
|
||||
remember(dto, itemPlayback) {
|
||||
chooseStream(
|
||||
dto,
|
||||
itemPlayback,
|
||||
MediaStreamType.VIDEO,
|
||||
preferences,
|
||||
)
|
||||
}
|
||||
val video =
|
||||
remember(videoStream) {
|
||||
videoStream
|
||||
|
|
@ -81,7 +85,7 @@ fun VideoStreamDetails(
|
|||
} else {
|
||||
null
|
||||
}
|
||||
val range = formatVideoRange(context, it.videoRange, it.videoRangeType)
|
||||
val range = formatVideoRange(context, it.videoRange, it.videoRangeType, it.videoDoViTitle)
|
||||
listOfNotNull(
|
||||
resName.concatWithSpace(range),
|
||||
it.codec?.uppercase(),
|
||||
|
|
@ -92,25 +96,18 @@ fun VideoStreamDetails(
|
|||
StreamLabel(it)
|
||||
}
|
||||
|
||||
val audioStream =
|
||||
remember(dto, itemPlayback) {
|
||||
chooseStream(
|
||||
dto,
|
||||
itemPlayback,
|
||||
MediaStreamType.AUDIO,
|
||||
preferences,
|
||||
)
|
||||
}
|
||||
val audioCount = remember(source) { source?.audioStreamCount ?: 0 }
|
||||
val audio =
|
||||
if (audioCount == 0 || audioStream == null) {
|
||||
stringResource(R.string.none)
|
||||
} else {
|
||||
listOfNotNull(
|
||||
languageName(audioStream.language),
|
||||
formatAudioCodec(context, audioStream.codec, audioStream.profile),
|
||||
audioStream.channelLayout,
|
||||
).joinToString(" ")
|
||||
remember(audioCount, audioStream) {
|
||||
if (audioCount == 0 || audioStream == null) {
|
||||
context.getString(R.string.none)
|
||||
} else {
|
||||
listOfNotNull(
|
||||
languageName(audioStream.language),
|
||||
formatAudioCodec(context, audioStream.codec, audioStream.profile),
|
||||
audioStream.channelLayout,
|
||||
).joinToString(" ")
|
||||
}
|
||||
}
|
||||
StreamLabel(
|
||||
text = audio,
|
||||
|
|
@ -119,30 +116,26 @@ fun VideoStreamDetails(
|
|||
modifier = Modifier.widthIn(max = 200.dp),
|
||||
)
|
||||
|
||||
val subtitleStream =
|
||||
remember(dto, itemPlayback) {
|
||||
chooseStream(
|
||||
dto,
|
||||
itemPlayback,
|
||||
MediaStreamType.SUBTITLE,
|
||||
preferences,
|
||||
)
|
||||
}
|
||||
val subtitleCount =
|
||||
remember(source) {
|
||||
(source?.embeddedSubtitleCount ?: 0) + (source?.externalSubtitlesCount ?: 0)
|
||||
}
|
||||
var disabled by remember { mutableStateOf(false) }
|
||||
val subtitle =
|
||||
if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) {
|
||||
stringResource(R.string.disabled)
|
||||
} else if (subtitleCount == 0 || subtitleStream == null) {
|
||||
null
|
||||
} else {
|
||||
listOfNotNull(
|
||||
languageName(subtitleStream.language),
|
||||
"SDH".takeIf { subtitleStream.isHearingImpaired },
|
||||
formatSubtitleCodec(subtitleStream.codec),
|
||||
).joinToString(" ")
|
||||
remember(subtitleCount, subtitleStream) {
|
||||
if (subtitleCount > 0 && subtitleStream == null) {
|
||||
disabled = true
|
||||
context.getString(R.string.disabled)
|
||||
} else if (subtitleCount == 0 || subtitleStream == null) {
|
||||
null
|
||||
} else {
|
||||
disabled = false
|
||||
listOfNotNull(
|
||||
languageName(subtitleStream.language),
|
||||
"SDH".takeIf { subtitleStream.isHearingImpaired },
|
||||
formatSubtitleCodec(subtitleStream.codec),
|
||||
).joinToString(" ")
|
||||
}
|
||||
}
|
||||
subtitle?.let {
|
||||
StreamLabel(
|
||||
|
|
@ -150,6 +143,7 @@ fun VideoStreamDetails(
|
|||
count = subtitleCount,
|
||||
icon = R.string.fa_closed_captioning,
|
||||
modifier = Modifier.widthIn(max = 160.dp),
|
||||
disabled = disabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -190,6 +184,7 @@ fun StreamLabel(
|
|||
modifier: Modifier = Modifier,
|
||||
@StringRes icon: Int? = null,
|
||||
count: Int = 0,
|
||||
disabled: Boolean = false,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
|
@ -222,9 +217,10 @@ fun StreamLabel(
|
|||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier,
|
||||
)
|
||||
if (count > 1) {
|
||||
val countToUse = if (disabled) count else count - 1
|
||||
if (countToUse > 0) {
|
||||
Text(
|
||||
text = "(+${count - 1})",
|
||||
text = "(+$countToUse)",
|
||||
maxLines = 1,
|
||||
modifier = Modifier,
|
||||
)
|
||||
|
|
@ -248,6 +244,10 @@ private fun StreamLabelPreview() {
|
|||
StreamLabel("HDR")
|
||||
StreamLabel("H264")
|
||||
StreamLabel("AC3 5.1", icon = R.string.fa_volume_high, count = 2)
|
||||
|
||||
StreamLabel("PGS", count = 1)
|
||||
StreamLabel("PGS", count = 1, disabled = true)
|
||||
StreamLabel("PGS", count = 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -256,6 +256,7 @@ fun formatVideoRange(
|
|||
context: Context,
|
||||
videoRange: VideoRange?,
|
||||
type: VideoRangeType?,
|
||||
doviTitle: String?,
|
||||
): String? =
|
||||
when (videoRange) {
|
||||
VideoRange.UNKNOWN,
|
||||
|
|
@ -265,23 +266,27 @@ fun formatVideoRange(
|
|||
}
|
||||
|
||||
VideoRange.HDR -> {
|
||||
when (type) {
|
||||
VideoRangeType.UNKNOWN,
|
||||
VideoRangeType.SDR,
|
||||
null,
|
||||
-> null
|
||||
if (doviTitle.isNotNullOrBlank()) {
|
||||
context.getString(R.string.dolby_vision)
|
||||
} else {
|
||||
when (type) {
|
||||
VideoRangeType.UNKNOWN,
|
||||
VideoRangeType.SDR,
|
||||
null,
|
||||
-> null
|
||||
|
||||
VideoRangeType.HDR10 -> "HDR10"
|
||||
VideoRangeType.HDR10 -> "HDR10"
|
||||
|
||||
VideoRangeType.HDR10_PLUS -> "HDR10+"
|
||||
VideoRangeType.HDR10_PLUS -> "HDR10+"
|
||||
|
||||
VideoRangeType.HLG -> "HLG"
|
||||
VideoRangeType.HLG -> "HLG"
|
||||
|
||||
VideoRangeType.DOVI,
|
||||
VideoRangeType.DOVI_WITH_HDR10,
|
||||
VideoRangeType.DOVI_WITH_HLG,
|
||||
VideoRangeType.DOVI_WITH_SDR,
|
||||
-> context.getString(R.string.dolby_vision)
|
||||
VideoRangeType.DOVI,
|
||||
VideoRangeType.DOVI_WITH_HDR10,
|
||||
VideoRangeType.DOVI_WITH_HLG,
|
||||
VideoRangeType.DOVI_WITH_SDR,
|
||||
-> context.getString(R.string.dolby_vision)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ data class ViewOptions(
|
|||
val aspectRatio: AspectRatio = AspectRatio.TALL,
|
||||
val showDetails: Boolean = false,
|
||||
val imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
|
||||
val showTitles: Boolean = true,
|
||||
) {
|
||||
companion object {
|
||||
val ViewOptionsColumns =
|
||||
|
|
@ -165,6 +166,13 @@ data class ViewOptions(
|
|||
getter = { it.showDetails },
|
||||
setter = { vo, value -> vo.copy(showDetails = value) },
|
||||
)
|
||||
val ViewOptionsShowTitles =
|
||||
AppSwitchPreference<ViewOptions>(
|
||||
title = R.string.show_titles,
|
||||
defaultValue = true,
|
||||
getter = { it.showTitles },
|
||||
setter = { vo, value -> vo.copy(showTitles = value) },
|
||||
)
|
||||
|
||||
val ViewOptionsImageType =
|
||||
AppChoicePreference<ViewOptions, ViewOptionImageType>(
|
||||
|
|
@ -194,6 +202,7 @@ data class ViewOptions(
|
|||
ViewOptionsImageType,
|
||||
ViewOptionsAspectRatio,
|
||||
ViewOptionsDetailHeader,
|
||||
ViewOptionsShowTitles,
|
||||
ViewOptionsColumns,
|
||||
ViewOptionsSpacing,
|
||||
ViewOptionsContentScale,
|
||||
|
|
|
|||
|
|
@ -1,28 +1,35 @@
|
|||
package com.github.damontecres.wholphin.ui.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.ProvideTextStyle
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.ui.byteRateSuffixes
|
||||
import com.github.damontecres.wholphin.ui.components.ScrollableDialog
|
||||
import com.github.damontecres.wholphin.ui.components.formatAudioCodec
|
||||
import com.github.damontecres.wholphin.ui.components.formatSubtitleCodec
|
||||
import com.github.damontecres.wholphin.ui.formatBytes
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||
import com.github.damontecres.wholphin.util.languageName
|
||||
import org.jellyfin.sdk.model.api.AudioSpatialFormat
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.MediaStream
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.VideoRange
|
||||
import org.jellyfin.sdk.model.api.VideoRangeType
|
||||
import java.util.Locale
|
||||
|
||||
data class ItemDetailsDialogInfo(
|
||||
|
|
@ -37,129 +44,338 @@ fun ItemDetailsDialog(
|
|||
info: ItemDetailsDialogInfo,
|
||||
showFilePath: Boolean,
|
||||
onDismissRequest: () -> Unit,
|
||||
) = ScrollableDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = info.title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
}
|
||||
if (info.genres.isNotEmpty()) {
|
||||
val context = LocalContext.current
|
||||
// Extract stringResource calls outside of ScrollableDialog's non-composable lambda
|
||||
val pathLabel = stringResource(R.string.path)
|
||||
val fileSizeLabel = stringResource(R.string.file_size)
|
||||
val videoLabel = stringResource(R.string.video)
|
||||
val audioLabel = stringResource(R.string.audio)
|
||||
val subtitleLabel = stringResource(R.string.subtitle)
|
||||
val bitrateLabel = stringResource(R.string.bitrate)
|
||||
val unknown = stringResource(R.string.unknown)
|
||||
|
||||
ScrollableDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
width = 720.dp,
|
||||
maxHeight = 480.dp,
|
||||
itemSpacing = 4.dp,
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = info.genres.joinToString(", "),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
text = info.title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (info.overview.isNotNullOrBlank()) {
|
||||
item {
|
||||
Text(
|
||||
text = info.overview,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
if (info.genres.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = info.genres.joinToString(", "),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (info.files.isNotEmpty()) {
|
||||
item {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
if (info.overview.isNotNullOrBlank()) {
|
||||
item {
|
||||
Text(
|
||||
text = info.overview,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
items(info.files) { file ->
|
||||
ProvideTextStyle(MaterialTheme.typography.bodyMedium) {
|
||||
MediaSourceInfo(file, showFilePath, Modifier.padding(top = 12.dp))
|
||||
|
||||
// Show detailed media information for the selected source (first one if multiple)
|
||||
info.files.forEachIndexed { index, source ->
|
||||
source.mediaStreams?.letNotEmpty { mediaStreams ->
|
||||
item {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
// General file information
|
||||
item {
|
||||
val containerLabel = stringResource(R.string.container)
|
||||
MediaInfoSection(
|
||||
title = stringResource(R.string.general),
|
||||
items =
|
||||
buildList {
|
||||
source.container?.let { add(containerLabel to it) }
|
||||
if (showFilePath) {
|
||||
source.path?.let { add(pathLabel to it) }
|
||||
add("ID" to (source.id ?: unknown))
|
||||
}
|
||||
source.size?.let {
|
||||
add(fileSizeLabel to formatBytes(it))
|
||||
}
|
||||
source.bitrate?.let {
|
||||
add(
|
||||
bitrateLabel to formatBytes(it, byteRateSuffixes),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Video streams
|
||||
items(mediaStreams.filter { it.type == MediaStreamType.VIDEO }) { stream ->
|
||||
MediaInfoSection(
|
||||
title = videoLabel,
|
||||
items = buildVideoStreamInfo(context, stream),
|
||||
)
|
||||
}
|
||||
|
||||
// Audio streams - display multiple per row
|
||||
items(
|
||||
mediaStreams
|
||||
.filter { it.type == MediaStreamType.AUDIO }
|
||||
.chunked(3),
|
||||
) { streamGroup ->
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
streamGroup.forEach { stream ->
|
||||
MediaInfoSection(
|
||||
title = audioLabel,
|
||||
items = buildAudioStreamInfo(context, stream),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
// Fill remaining space if less than 3 items
|
||||
repeat(3 - streamGroup.size) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Subtitle streams - display multiple per row
|
||||
items(
|
||||
mediaStreams
|
||||
.filter { it.type == MediaStreamType.SUBTITLE }
|
||||
.chunked(3),
|
||||
) { streamGroup ->
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
streamGroup.forEach { stream ->
|
||||
MediaInfoSection(
|
||||
title = subtitleLabel,
|
||||
items = buildSubtitleStreamInfo(context, stream),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
// Fill remaining space if less than 3 items
|
||||
repeat(3 - streamGroup.size) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (index != info.files.lastIndex) {
|
||||
item {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MediaSourceInfo(
|
||||
source: MediaSourceInfo,
|
||||
showFilePath: Boolean,
|
||||
private fun MediaInfoSection(
|
||||
title: String,
|
||||
items: List<Pair<String, String>>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
modifier = modifier.padding(vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.name) + ": ${source.name}",
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = "ID: ${source.id}",
|
||||
)
|
||||
if (showFilePath) {
|
||||
Text(
|
||||
text = stringResource(R.string.path) + ": ${source.path}",
|
||||
)
|
||||
}
|
||||
source.size?.let { size ->
|
||||
Text(
|
||||
text = stringResource(R.string.file_size) + ": ${formatBytes(size)}",
|
||||
)
|
||||
}
|
||||
source.bitrate?.let { bitrate ->
|
||||
Text(
|
||||
text =
|
||||
stringResource(R.string.bitrate) + ": ${
|
||||
formatBytes(
|
||||
bitrate,
|
||||
byteRateSuffixes,
|
||||
)
|
||||
}",
|
||||
)
|
||||
}
|
||||
source.mediaStreams?.letNotEmpty { streams ->
|
||||
streams.filter { it.type == MediaStreamType.VIDEO }.forEach { stream ->
|
||||
val data =
|
||||
buildList {
|
||||
if (stream.width != null && stream.height != null) {
|
||||
add("${stream.width}x${stream.height}")
|
||||
}
|
||||
stream.averageFrameRate?.let {
|
||||
add(String.format(Locale.getDefault(), "%.3f", it) + " fps")
|
||||
}
|
||||
stream.bitRate?.let { add(formatBytes(it, byteRateSuffixes)) }
|
||||
stream.codec?.let(::add)
|
||||
stream.profile?.let(::add)
|
||||
}
|
||||
items.forEach { (label, value) ->
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.video) + ": " + data.joinToString(" - "),
|
||||
text = "$label: ",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
|
||||
streams.filter { it.type == MediaStreamType.AUDIO }.forEachIndexed { index, stream ->
|
||||
val data =
|
||||
buildList {
|
||||
stream.language?.let { add(languageName(it)) }
|
||||
stream.codec?.let(::add)
|
||||
stream.channelLayout?.let(::add)
|
||||
stream.bitRate?.let { add(formatBytes(it, byteRateSuffixes)) }
|
||||
if (stream.audioSpatialFormat != AudioSpatialFormat.NONE) add(stream.audioSpatialFormat.serialName)
|
||||
if (stream.isDefault) add(stringResource(R.string.default_track))
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.audio) + " ${index + 1}: " + data.joinToString(" - "),
|
||||
)
|
||||
}
|
||||
|
||||
streams.filter { it.type == MediaStreamType.SUBTITLE }.forEachIndexed { index, stream ->
|
||||
val data =
|
||||
buildList {
|
||||
stream.language?.let { add(languageName(it)) }
|
||||
stream.codec?.let(::add)
|
||||
if (stream.isDefault) add(stringResource(R.string.default_track))
|
||||
if (stream.isForced) add(stringResource(R.string.forced_track))
|
||||
if (stream.isExternal) add(stringResource(R.string.external_track))
|
||||
}
|
||||
Text(
|
||||
text =
|
||||
stringResource(R.string.subtitle) + " ${index + 1}: " +
|
||||
data.joinToString(" - "),
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildVideoStreamInfo(
|
||||
context: Context,
|
||||
stream: MediaStream,
|
||||
): List<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)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
|||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.lazy.layout.LazyLayoutCacheWindow
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -33,11 +34,14 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
|
|
@ -49,6 +53,8 @@ import androidx.compose.ui.res.stringResource
|
|||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.Button
|
||||
import androidx.tv.material3.ButtonDefaults
|
||||
import androidx.tv.material3.LocalContentColor
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
|
|
@ -57,8 +63,6 @@ import com.github.damontecres.wholphin.data.model.BaseItem
|
|||
import com.github.damontecres.wholphin.ui.AppColors
|
||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||
import com.github.damontecres.wholphin.ui.cards.GridCard
|
||||
import com.github.damontecres.wholphin.ui.components.Button
|
||||
import com.github.damontecres.wholphin.ui.components.TextButton
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.playback.isBackwardButton
|
||||
import com.github.damontecres.wholphin.ui.playback.isForwardButton
|
||||
|
|
@ -69,15 +73,23 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
|
||||
private const val DEBUG = false
|
||||
|
||||
interface CardGridItem {
|
||||
val id: UUID
|
||||
val playable: Boolean
|
||||
val sortName: String
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun CardGrid(
|
||||
pager: List<BaseItem?>,
|
||||
onClickItem: (Int, BaseItem) -> Unit,
|
||||
onLongClickItem: (Int, BaseItem) -> Unit,
|
||||
fun <T : CardGridItem> CardGrid(
|
||||
pager: List<T?>,
|
||||
onClickItem: (Int, T) -> Unit,
|
||||
onLongClickItem: (Int, T) -> Unit,
|
||||
onClickPlay: (Int, T) -> Unit,
|
||||
letterPosition: suspend (Char) -> Int,
|
||||
gridFocusRequester: FocusRequester,
|
||||
showJumpButtons: Boolean,
|
||||
|
|
@ -86,13 +98,13 @@ fun CardGrid(
|
|||
initialPosition: Int = 0,
|
||||
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
||||
cardContent: @Composable (
|
||||
item: BaseItem?,
|
||||
item: T?,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
mod: Modifier,
|
||||
) -> Unit = { item, onClick, onLongClick, mod ->
|
||||
GridCard(
|
||||
item = item,
|
||||
item = item as BaseItem?,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
imageContentScale = ContentScale.FillBounds,
|
||||
|
|
@ -213,7 +225,11 @@ fun CardGrid(
|
|||
jumpToTop()
|
||||
return@onKeyEvent true
|
||||
} else if (isPlayKeyUp(it)) {
|
||||
// TODO play the focused item
|
||||
val item = pager.getOrNull(focusedIndex)
|
||||
if (item?.playable == true) {
|
||||
Timber.v("Clicked play on ${item.id}")
|
||||
onClickPlay.invoke(focusedIndex, item)
|
||||
}
|
||||
return@onKeyEvent true
|
||||
} else if (useJumpRemoteButtons && isForwardButton(it)) {
|
||||
jump(jump1)
|
||||
|
|
@ -341,7 +357,6 @@ fun CardGrid(
|
|||
remember(focusedIndex) {
|
||||
pager
|
||||
.getOrNull(focusedIndex)
|
||||
?.data
|
||||
?.sortName
|
||||
?.first()
|
||||
?.uppercaseChar()
|
||||
|
|
@ -360,7 +375,11 @@ fun CardGrid(
|
|||
AlphabetButtons(
|
||||
letters = letters,
|
||||
currentLetter = currentLetter,
|
||||
modifier = Modifier.align(Alignment.CenterVertically),
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.CenterVertically)
|
||||
.padding(end = 16.dp),
|
||||
// Add end padding to push away from edge
|
||||
letterClicked = { letter ->
|
||||
scope.launch(ExceptionHandler()) {
|
||||
val jumpPosition =
|
||||
|
|
@ -369,6 +388,7 @@ fun CardGrid(
|
|||
}
|
||||
Timber.d("Alphabet jump to $jumpPosition")
|
||||
if (jumpPosition >= 0) {
|
||||
pager.getOrNull(jumpPosition)
|
||||
gridState.scrollToItem(jumpPosition)
|
||||
focusOn(jumpPosition)
|
||||
alphabetFocus = true
|
||||
|
|
@ -422,7 +442,6 @@ fun AlphabetButtons(
|
|||
letterClicked: (Char) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
|
|
@ -434,51 +453,97 @@ fun AlphabetButtons(
|
|||
listState.layoutInfo.visibleItemsInfo
|
||||
.lastOrNull()
|
||||
?.index ?: -1
|
||||
if (index < firstVisibleItemIndex || index > lastVisibleItemIndex) {
|
||||
if (index !in firstVisibleItemIndex..lastVisibleItemIndex) {
|
||||
listState.animateScrollToItem(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Focus & interaction states for each letter button
|
||||
val focusRequesters = remember { List(letters.length) { FocusRequester() } }
|
||||
val interactionSources = remember { List(letters.length) { MutableInteractionSource() } }
|
||||
|
||||
// Track if the entire alphabet picker component has focus
|
||||
var alphabetPickerFocused by remember { mutableStateOf(false) }
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(4.dp),
|
||||
contentPadding = PaddingValues(vertical = 1.1.dp, horizontal = 2.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(1.1.dp),
|
||||
state = listState,
|
||||
modifier =
|
||||
modifier.focusProperties {
|
||||
onEnter = {
|
||||
focusRequesters[index.coerceIn(0, letters.length - 1)].tryRequestFocus()
|
||||
}
|
||||
},
|
||||
modifier
|
||||
.onFocusChanged { focusState ->
|
||||
alphabetPickerFocused = focusState.hasFocus
|
||||
}.focusProperties {
|
||||
onEnter = {
|
||||
focusRequesters[index.coerceIn(0, letters.length - 1)].tryRequestFocus()
|
||||
}
|
||||
},
|
||||
) {
|
||||
items(
|
||||
letters.length,
|
||||
key = { letters[it] },
|
||||
) { index ->
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val interactionSource = interactionSources[index]
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
TextButton(
|
||||
|
||||
val isCurrentLetter = letters[index] == currentLetter
|
||||
// Apply alpha to individual items, but keep selected letter fully visible when picker is unfocused
|
||||
val itemAlpha =
|
||||
when {
|
||||
isCurrentLetter && !alphabetPickerFocused -> 1f
|
||||
alphabetPickerFocused -> .85f
|
||||
else -> .25f
|
||||
}
|
||||
|
||||
// Only show circle background for the current letter (or when focused)
|
||||
// Wrap in Box with clipping to prevent focus indicator from overflowing
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(24.dp)
|
||||
.focusRequester(focusRequesters[index]),
|
||||
contentPadding = PaddingValues(2.dp),
|
||||
interactionSource = interactionSource,
|
||||
onClick = {
|
||||
letterClicked.invoke(letters[index])
|
||||
},
|
||||
.size(14.dp)
|
||||
.clip(CircleShape)
|
||||
.alpha(itemAlpha),
|
||||
) {
|
||||
val color =
|
||||
if (!focused && letters[index] == currentLetter) {
|
||||
MaterialTheme.colorScheme.tertiary
|
||||
} else {
|
||||
LocalContentColor.current
|
||||
}
|
||||
Text(
|
||||
text = letters[index].toString(),
|
||||
color = color,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Button(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(14.dp)
|
||||
.focusRequester(focusRequesters[index]),
|
||||
contentPadding = PaddingValues(0.dp), // No padding to maximize text space
|
||||
interactionSource = interactionSource,
|
||||
onClick = {
|
||||
letterClicked.invoke(letters[index])
|
||||
},
|
||||
colors =
|
||||
if (isCurrentLetter || focused) {
|
||||
// Use default button colors for current letter or focused
|
||||
ButtonDefaults.colors()
|
||||
} else {
|
||||
// Transparent background for non-current letters (no circle)
|
||||
ButtonDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
focusedContainerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
focusedContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
},
|
||||
) {
|
||||
// Use border color for selected letter when focused, tertiary for unfocused-selected
|
||||
val color =
|
||||
when {
|
||||
isCurrentLetter && focused -> MaterialTheme.colorScheme.border
|
||||
isCurrentLetter -> MaterialTheme.colorScheme.tertiary
|
||||
focused -> LocalContentColor.current
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
Text(
|
||||
text = letters[index].toString(),
|
||||
color = color,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import com.github.damontecres.wholphin.data.ServerRepository
|
|||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
|
|
@ -59,6 +60,7 @@ class LiveTvCollectionViewModel
|
|||
val serverRepository: ServerRepository,
|
||||
val navigationManager: NavigationManager,
|
||||
val rememberTabManager: RememberTabManager,
|
||||
val backdropService: BackdropService,
|
||||
) : ViewModel(),
|
||||
RememberTabManager by rememberTabManager {
|
||||
val recordingFolders = MutableLiveData<List<TabId>>()
|
||||
|
|
@ -106,6 +108,7 @@ fun CollectionFolderLiveTv(
|
|||
LaunchedEffect(selectedTabIndex) {
|
||||
logTab("livetv", selectedTabIndex)
|
||||
viewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
|
||||
viewModel.backdropService.clearBackdrop()
|
||||
}
|
||||
val onClickItem = { position: Int, item: BaseItem ->
|
||||
viewModel.navigationManager.navigateTo(item.destination())
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ fun CollectionFolderMovie(
|
|||
LaunchedEffect(selectedTabIndex) {
|
||||
logTab("movie", selectedTabIndex)
|
||||
preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
|
||||
preferencesViewModel.backdropService.clearBackdrop()
|
||||
}
|
||||
|
||||
var showHeader by rememberSaveable { mutableStateOf(true) }
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ fun CollectionFolderTv(
|
|||
LaunchedEffect(selectedTabIndex) {
|
||||
logTab("tv", selectedTabIndex)
|
||||
preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
|
||||
preferencesViewModel.backdropService.clearBackdrop()
|
||||
}
|
||||
|
||||
val onClickItem = { item: BaseItem ->
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import com.github.damontecres.wholphin.data.ItemPlaybackDao
|
|||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.RefreshRateService
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
|
|
@ -58,6 +59,9 @@ class DebugViewModel
|
|||
constructor(
|
||||
val serverRepository: ServerRepository,
|
||||
val itemPlaybackDao: ItemPlaybackDao,
|
||||
val refreshRateService: RefreshRateService,
|
||||
val clientInfo: ClientInfo,
|
||||
val deviceInfo: DeviceInfo,
|
||||
) : ViewModel() {
|
||||
val itemPlaybacks = MutableLiveData<List<ItemPlayback>>(listOf())
|
||||
val logcat = MutableLiveData<List<LogcatLine>>(listOf())
|
||||
|
|
@ -220,6 +224,7 @@ fun DebugPage(
|
|||
listOf(
|
||||
"Version Name: ${pkgInfo.versionName}",
|
||||
"Version Code: ${pkgInfo.versionCodeLong}",
|
||||
"ClientInfo: ${viewModel.clientInfo}",
|
||||
"Build type: ${BuildConfig.BUILD_TYPE}",
|
||||
"Debug enabled: ${BuildConfig.DEBUG}",
|
||||
"ABIs: ${Build.SUPPORTED_ABIS.toList()}",
|
||||
|
|
@ -233,6 +238,32 @@ fun DebugPage(
|
|||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = "Device Information",
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
||||
listOf(
|
||||
"DeviceInfo: ${viewModel.deviceInfo}",
|
||||
"Manufacturer: ${Build.MANUFACTURER}",
|
||||
"Model: ${Build.MODEL}",
|
||||
"Display Modes:",
|
||||
*viewModel.refreshRateService.displayModes,
|
||||
).forEach {
|
||||
Text(
|
||||
text = it.toString(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import android.content.Context
|
|||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
|
@ -43,6 +44,7 @@ data class MoreDialogActions(
|
|||
* @param navigateTo a function to trigger a navigation
|
||||
* @param onChooseVersion callback to pick a version of the item
|
||||
* @param onChooseTracks callback to pick a track for the given type of the item
|
||||
* @param onShowOverview callback to show overview dialog with media information
|
||||
*/
|
||||
fun buildMoreDialogItems(
|
||||
context: Context,
|
||||
|
|
@ -54,6 +56,7 @@ fun buildMoreDialogItems(
|
|||
actions: MoreDialogActions,
|
||||
onChooseVersion: () -> Unit,
|
||||
onChooseTracks: (MediaStreamType) -> Unit,
|
||||
onShowOverview: () -> Unit,
|
||||
): List<DialogItem> =
|
||||
buildList {
|
||||
add(
|
||||
|
|
@ -159,6 +162,16 @@ fun buildMoreDialogItems(
|
|||
},
|
||||
)
|
||||
}
|
||||
if (item.data.mediaSources?.isNotEmpty() == true) {
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.media_information),
|
||||
Icons.Default.Info,
|
||||
) {
|
||||
onShowOverview.invoke()
|
||||
},
|
||||
)
|
||||
}
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.play_with_transcoding),
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ fun FavoritesPage(
|
|||
NavDrawerItem.Favorites.id,
|
||||
selectedTabIndex,
|
||||
)
|
||||
preferencesViewModel.backdropService.clearBackdrop()
|
||||
}
|
||||
var showHeader by rememberSaveable { mutableStateOf(true) }
|
||||
|
||||
|
|
|
|||
|
|
@ -50,10 +50,11 @@ import androidx.tv.material3.Text
|
|||
import androidx.tv.material3.surfaceColorAtElevation
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.ui.DefaultItemFields
|
||||
import com.github.damontecres.wholphin.ui.TimeFormatter
|
||||
import com.github.damontecres.wholphin.ui.cards.ItemCardImage
|
||||
import com.github.damontecres.wholphin.ui.components.DelayedDetailsBackdropImage
|
||||
import com.github.damontecres.wholphin.ui.components.DialogItem
|
||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||
|
|
@ -64,9 +65,11 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage
|
|||
import com.github.damontecres.wholphin.ui.components.OverviewText
|
||||
import com.github.damontecres.wholphin.ui.enableMarquee
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.roundMinutes
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.ui.util.LocalClock
|
||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||
import com.github.damontecres.wholphin.util.GetPlaylistItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||
|
|
@ -88,6 +91,7 @@ class PlaylistViewModel
|
|||
constructor(
|
||||
api: ApiClient,
|
||||
val navigationManager: NavigationManager,
|
||||
private val backdropService: BackdropService,
|
||||
) : ItemViewModel(api) {
|
||||
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||
val items = MutableLiveData<List<BaseItem?>>(listOf())
|
||||
|
|
@ -111,6 +115,12 @@ class PlaylistViewModel
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateBackdrop(item: BaseItem) {
|
||||
viewModelScope.launchIO {
|
||||
backdropService.submit(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -146,6 +156,7 @@ fun PlaylistDetails(
|
|||
playlist = it,
|
||||
items = items,
|
||||
focusRequester = focusRequester,
|
||||
onChangeBackdrop = viewModel::updateBackdrop,
|
||||
onClickIndex = { index, _ ->
|
||||
viewModel.navigationManager.navigateTo(
|
||||
Destination.PlaybackList(
|
||||
|
|
@ -212,6 +223,7 @@ fun PlaylistDetailsContent(
|
|||
onClickIndex: (Int, BaseItem) -> Unit,
|
||||
onLongClickIndex: (Int, BaseItem) -> Unit,
|
||||
onClickPlay: (shuffle: Boolean) -> Unit,
|
||||
onChangeBackdrop: (BaseItem) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
focusRequester: FocusRequester = remember { FocusRequester() },
|
||||
) {
|
||||
|
|
@ -219,13 +231,15 @@ fun PlaylistDetailsContent(
|
|||
var focusedIndex by remember { mutableIntStateOf(savedIndex) }
|
||||
val focus = remember { FocusRequester() }
|
||||
val focusedItem = items.getOrNull(focusedIndex)
|
||||
LaunchedEffect(focusedItem) {
|
||||
focusedItem?.let(onChangeBackdrop)
|
||||
}
|
||||
|
||||
val playButtonFocusRequester = remember { FocusRequester() }
|
||||
|
||||
Box(
|
||||
modifier = modifier,
|
||||
) {
|
||||
DelayedDetailsBackdropImage(focusedItem)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier =
|
||||
|
|
@ -395,10 +409,22 @@ fun PlaylistItem(
|
|||
)
|
||||
},
|
||||
trailingContent = {
|
||||
item?.data?.runTimeTicks?.ticks?.roundMinutes?.let {
|
||||
Text(
|
||||
text = it.toString(),
|
||||
)
|
||||
item?.data?.runTimeTicks?.ticks?.roundMinutes?.let { duration ->
|
||||
val now = LocalClock.current.now
|
||||
val endTimeStr =
|
||||
remember(item, now) {
|
||||
val endTime = now.toLocalTime().plusSeconds(duration.inWholeSeconds)
|
||||
TimeFormatter.format(endTime)
|
||||
}
|
||||
Column {
|
||||
Text(
|
||||
text = duration.toString(),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.ends_at, endTimeStr),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
leadingContent = {
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ import androidx.compose.foundation.layout.size
|
|||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
|
|
@ -20,6 +20,7 @@ import androidx.compose.runtime.LaunchedEffect
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -133,7 +134,7 @@ fun PlaylistList(
|
|||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
elevation = 10.dp,
|
||||
) {
|
||||
val playlistName = rememberTextFieldState()
|
||||
var playlistName by rememberSaveable { mutableStateOf("") }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||
Column(
|
||||
|
|
@ -148,7 +149,8 @@ fun PlaylistList(
|
|||
text = stringResource(R.string.name),
|
||||
)
|
||||
EditTextBox(
|
||||
state = playlistName,
|
||||
value = playlistName,
|
||||
onValueChange = { playlistName = it },
|
||||
keyboardOptions =
|
||||
KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.Words,
|
||||
|
|
@ -156,10 +158,17 @@ fun PlaylistList(
|
|||
keyboardType = KeyboardType.Text,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
onKeyboardAction = {
|
||||
showCreateDialog = false
|
||||
onCreatePlaylist.invoke(playlistName.text.toString())
|
||||
},
|
||||
keyboardActions =
|
||||
KeyboardActions(
|
||||
onDone = {
|
||||
showCreateDialog = false
|
||||
onCreatePlaylist.invoke(playlistName)
|
||||
},
|
||||
),
|
||||
// onKeyboardAction = {
|
||||
// showCreateDialog = false
|
||||
// onCreatePlaylist.invoke(playlistName.text.toString())
|
||||
// },
|
||||
modifier =
|
||||
Modifier
|
||||
.focusRequester(focusRequester)
|
||||
|
|
@ -168,9 +177,9 @@ fun PlaylistList(
|
|||
Button(
|
||||
onClick = {
|
||||
showCreateDialog = false
|
||||
onCreatePlaylist.invoke(playlistName.text.toString())
|
||||
onCreatePlaylist.invoke(playlistName)
|
||||
},
|
||||
enabled = playlistName.text.isNotNullOrBlank(),
|
||||
enabled = playlistName.isNotNullOrBlank(),
|
||||
modifier = Modifier,
|
||||
) {
|
||||
Text(text = stringResource(R.string.submit))
|
||||
|
|
|
|||
|
|
@ -30,9 +30,7 @@ import androidx.lifecycle.compose.LifecycleStartEffect
|
|||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.ChosenStreams
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.chooseSource
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.ui.components.DetailsBackdropImage
|
||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
|
|
@ -57,6 +55,7 @@ import kotlinx.coroutines.launch
|
|||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import org.jellyfin.sdk.model.serializer.toUUID
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration
|
||||
|
||||
|
|
@ -157,7 +156,7 @@ fun EpisodeDetails(
|
|||
watched = ep.data.userData?.played ?: false,
|
||||
favorite = ep.data.userData?.isFavorite ?: false,
|
||||
seriesId = ep.data.seriesId,
|
||||
sourceId = chosenStreams?.sourceId,
|
||||
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
|
||||
actions = moreActions,
|
||||
onChooseVersion = {
|
||||
chooseVersion =
|
||||
|
|
@ -174,23 +173,36 @@ fun EpisodeDetails(
|
|||
moreDialog = null
|
||||
},
|
||||
onChooseTracks = { type ->
|
||||
chooseSource(
|
||||
ep.data,
|
||||
chosenStreams?.itemPlayback,
|
||||
)?.let { source ->
|
||||
chooseVersion =
|
||||
chooseStream(
|
||||
context = context,
|
||||
streams = source.mediaStreams.orEmpty(),
|
||||
type = type,
|
||||
onClick = { trackIndex ->
|
||||
viewModel.saveTrackSelection(
|
||||
ep,
|
||||
chosenStreams?.itemPlayback,
|
||||
trackIndex,
|
||||
type,
|
||||
)
|
||||
},
|
||||
viewModel.streamChoiceService
|
||||
.chooseSource(
|
||||
ep.data,
|
||||
chosenStreams?.itemPlayback,
|
||||
)?.let { source ->
|
||||
chooseVersion =
|
||||
chooseStream(
|
||||
context = context,
|
||||
streams = source.mediaStreams.orEmpty(),
|
||||
type = type,
|
||||
onClick = { trackIndex ->
|
||||
viewModel.saveTrackSelection(
|
||||
ep,
|
||||
chosenStreams?.itemPlayback,
|
||||
trackIndex,
|
||||
type,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
onShowOverview = {
|
||||
val source = chosenStreams?.source ?: ep.data.mediaSources?.firstOrNull()
|
||||
if (source != null) {
|
||||
overviewDialog =
|
||||
ItemDetailsDialogInfo(
|
||||
title = ep.name ?: context.getString(R.string.unknown),
|
||||
overview = ep.data.overview,
|
||||
genres = ep.data.genres.orEmpty(),
|
||||
files = listOf(source),
|
||||
)
|
||||
}
|
||||
},
|
||||
|
|
@ -283,7 +295,6 @@ fun EpisodeDetailsContent(
|
|||
focusRequesters.getOrNull(position)?.tryRequestFocus()
|
||||
}
|
||||
Box(modifier = modifier) {
|
||||
DetailsBackdropImage(ep)
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp),
|
||||
|
|
|
|||
|
|
@ -58,9 +58,7 @@ fun EpisodeDetailsHeader(
|
|||
EpisodeQuickDetails(dto)
|
||||
|
||||
VideoStreamDetails(
|
||||
preferences = preferences,
|
||||
dto = dto,
|
||||
itemPlayback = chosenStreams?.itemPlayback,
|
||||
chosenStreams = chosenStreams,
|
||||
modifier = Modifier.padding(bottom = padding),
|
||||
)
|
||||
dto.taglines?.firstOrNull()?.let { tagline ->
|
||||
|
|
|
|||
|
|
@ -10,9 +10,12 @@ import com.github.damontecres.wholphin.data.ServerRepository
|
|||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||
import com.github.damontecres.wholphin.preferences.ThemeSongVolume
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||
import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
|
|
@ -44,8 +47,11 @@ class EpisodeViewModel
|
|||
private val navigationManager: NavigationManager,
|
||||
val serverRepository: ServerRepository,
|
||||
val itemPlaybackRepository: ItemPlaybackRepository,
|
||||
val streamChoiceService: StreamChoiceService,
|
||||
private val themeSongPlayer: ThemeSongPlayer,
|
||||
private val favoriteWatchManager: FavoriteWatchManager,
|
||||
private val userPreferencesService: UserPreferencesService,
|
||||
private val backdropService: BackdropService,
|
||||
@Assisted val itemId: UUID,
|
||||
) : ViewModel() {
|
||||
@AssistedFactory
|
||||
|
|
@ -85,12 +91,14 @@ class EpisodeViewModel
|
|||
"Error fetching movie",
|
||||
),
|
||||
) {
|
||||
val prefs = userPreferencesService.getCurrent()
|
||||
val item = fetchAndSetItem().await()
|
||||
val result = itemPlaybackRepository.getSelectedTracks(item.id, item)
|
||||
val result = itemPlaybackRepository.getSelectedTracks(item.id, item, prefs)
|
||||
withContext(Dispatchers.Main) {
|
||||
this@EpisodeViewModel.item.value = item
|
||||
chosenStreams.value = result
|
||||
loading.value = LoadingState.Success
|
||||
backdropService.submit(item)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -115,10 +123,12 @@ class EpisodeViewModel
|
|||
sourceId: UUID,
|
||||
) {
|
||||
viewModelScope.launchIO {
|
||||
val prefs = userPreferencesService.getCurrent()
|
||||
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
||||
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
|
||||
val chosen =
|
||||
result?.let {
|
||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result)
|
||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
chosenStreams.value = chosen
|
||||
|
|
@ -133,6 +143,8 @@ class EpisodeViewModel
|
|||
type: MediaStreamType,
|
||||
) {
|
||||
viewModelScope.launchIO {
|
||||
val prefs = userPreferencesService.getCurrent()
|
||||
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
||||
val result =
|
||||
itemPlaybackRepository.saveTrackSelection(
|
||||
item = item,
|
||||
|
|
@ -142,7 +154,7 @@ class EpisodeViewModel
|
|||
)
|
||||
val chosen =
|
||||
result?.let {
|
||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result)
|
||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
chosenStreams.value = chosen
|
||||
|
|
|
|||
|
|
@ -10,8 +10,10 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
|
@ -20,60 +22,99 @@ import androidx.tv.material3.Text
|
|||
import androidx.tv.material3.contentColorFor
|
||||
import androidx.tv.material3.surfaceColorAtElevation
|
||||
import coil3.compose.AsyncImage
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Composable
|
||||
fun Program(
|
||||
guideStart: LocalDateTime,
|
||||
program: TvProgram,
|
||||
focused: Boolean,
|
||||
colorCode: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val background =
|
||||
if (focused) {
|
||||
MaterialTheme.colorScheme.inverseSurface
|
||||
} else {
|
||||
} else if (colorCode) {
|
||||
program.category?.color
|
||||
?: MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)
|
||||
}
|
||||
val textColor = MaterialTheme.colorScheme.contentColorFor(background)
|
||||
val startedBeforeGuide = program.start.isBefore(guideStart)
|
||||
val shape =
|
||||
remember(startedBeforeGuide) {
|
||||
val cornerSize = 4.dp
|
||||
if (startedBeforeGuide) {
|
||||
RoundedCornerShape(
|
||||
topEnd = cornerSize,
|
||||
bottomEnd = cornerSize,
|
||||
topStart = 0.dp,
|
||||
bottomStart = 0.dp,
|
||||
)
|
||||
} else {
|
||||
RoundedCornerShape(cornerSize)
|
||||
}
|
||||
}
|
||||
val title = program.name ?: program.id.toString()
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.padding(2.dp)
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
background,
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = background,
|
||||
shape = shape,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(4.dp),
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Text(
|
||||
text = program.name ?: program.id.toString(),
|
||||
color = textColor,
|
||||
fontSize = 16.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier,
|
||||
)
|
||||
listOfNotNull(
|
||||
program.seasonEpisode?.let { "S${it.season} E${it.episode}" },
|
||||
program.subtitle,
|
||||
).joinToString(" - ")
|
||||
.ifBlank { null }
|
||||
?.let {
|
||||
Text(
|
||||
text = it,
|
||||
color = textColor,
|
||||
fontSize = 14.sp,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
if (startedBeforeGuide) {
|
||||
Text(
|
||||
text = stringResource(R.string.fa_caret_left),
|
||||
fontFamily = FontAwesome,
|
||||
color = textColor,
|
||||
fontSize = 16.sp,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.CenterVertically)
|
||||
.padding(start = 2.dp),
|
||||
)
|
||||
}
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(start = 2.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
color = textColor,
|
||||
fontSize = 16.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier,
|
||||
)
|
||||
listOfNotNull(
|
||||
program.seasonEpisode?.let { "S${it.season} E${it.episode}" },
|
||||
program.subtitle,
|
||||
).joinToString(" - ")
|
||||
.ifBlank { null }
|
||||
?.let {
|
||||
Text(
|
||||
text = it,
|
||||
color = textColor,
|
||||
fontSize = 14.sp,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
RecordingMarker(
|
||||
isRecording = program.isRecording,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.detail.livetv
|
|||
|
||||
import android.content.Context
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
|
|
@ -9,6 +10,7 @@ import com.github.damontecres.wholphin.R
|
|||
import com.github.damontecres.wholphin.WholphinApplication
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.ui.AppColors
|
||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||
|
|
@ -23,7 +25,13 @@ import com.github.damontecres.wholphin.util.LoadingState
|
|||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
|
@ -34,7 +42,9 @@ import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
|||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.GetProgramsDto
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.SortOrder
|
||||
import org.jellyfin.sdk.model.api.TimerInfoDto
|
||||
import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
|
|
@ -45,11 +55,13 @@ import java.util.UUID
|
|||
import javax.inject.Inject
|
||||
import kotlin.math.min
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
const val MAX_HOURS = 48L
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
@HiltViewModel
|
||||
class LiveTvViewModel
|
||||
@Inject
|
||||
|
|
@ -57,15 +69,15 @@ class LiveTvViewModel
|
|||
@param:ApplicationContext private val context: Context,
|
||||
val api: ApiClient,
|
||||
val navigationManager: NavigationManager,
|
||||
val preferences: DataStore<AppPreferences>,
|
||||
private val serverRepository: ServerRepository,
|
||||
) : ViewModel() {
|
||||
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||
|
||||
lateinit var guideStart: LocalDateTime
|
||||
private set
|
||||
private lateinit var channelsIdToIndex: Map<UUID, Int>
|
||||
private val mutex = Mutex()
|
||||
|
||||
val guideTimes = MutableLiveData<List<LocalDateTime>>(buildGuideTimes())
|
||||
val channels = MutableLiveData<List<TvChannel>>()
|
||||
val channelProgramCount = mutableMapOf<UUID, Int>()
|
||||
val programs = MutableLiveData<FetchedPrograms>()
|
||||
|
|
@ -75,11 +87,25 @@ class LiveTvViewModel
|
|||
|
||||
private val range = 100
|
||||
|
||||
fun init(firstLoad: Boolean) {
|
||||
guideStart = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS)
|
||||
if (!firstLoad) {
|
||||
loading.value = LoadingState.Loading
|
||||
init {
|
||||
viewModelScope.launchIO {
|
||||
preferences.data
|
||||
.map {
|
||||
it.interfacePreferences.liveTvPreferences.let {
|
||||
Pair(it.sortByRecentlyWatched, it.favoriteChannelsAtBeginning)
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
.debounce { 500.milliseconds }
|
||||
.collectLatest {
|
||||
Timber.v("Init due to pref change")
|
||||
loading.setValueOnMain(LoadingState.Pending)
|
||||
init()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun init() {
|
||||
val guideStart = guideTimes.value!!.first()
|
||||
viewModelScope.launch(
|
||||
Dispatchers.IO +
|
||||
LoadingExceptionHandler(
|
||||
|
|
@ -87,11 +113,26 @@ class LiveTvViewModel
|
|||
"Could not fetch channels",
|
||||
),
|
||||
) {
|
||||
val prefs =
|
||||
(preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance())
|
||||
.interfacePreferences.liveTvPreferences
|
||||
val channelData by api.liveTvApi.getLiveTvChannels(
|
||||
GetLiveTvChannelsRequest(
|
||||
startIndex = 0,
|
||||
userId = serverRepository.currentUser.value?.id,
|
||||
enableFavoriteSorting = true,
|
||||
enableFavoriteSorting = prefs.favoriteChannelsAtBeginning,
|
||||
sortBy =
|
||||
if (prefs.sortByRecentlyWatched) {
|
||||
listOf(ItemSortBy.DATE_PLAYED)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
sortOrder =
|
||||
if (prefs.sortByRecentlyWatched) {
|
||||
SortOrder.DESCENDING
|
||||
} else {
|
||||
null
|
||||
},
|
||||
addCurrentProgram = false,
|
||||
),
|
||||
)
|
||||
|
|
@ -111,7 +152,7 @@ class LiveTvViewModel
|
|||
// Initially, quickly load the first 10 channels (only some are visible immediately), then below will load more
|
||||
// This makes the guide appear faster, and load more usable data in the background
|
||||
val initial = 10
|
||||
fetchPrograms(channels, 0..<initial.coerceAtMost(channels.size))
|
||||
fetchPrograms(guideStart, channels, 0..<initial.coerceAtMost(channels.size))
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
this@LiveTvViewModel.channels.value = channels
|
||||
|
|
@ -119,21 +160,35 @@ class LiveTvViewModel
|
|||
}
|
||||
// Now load the full range
|
||||
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(
|
||||
channels: List<TvChannel>,
|
||||
range: IntRange,
|
||||
) {
|
||||
loading.setValueOnMain(LoadingState.Loading)
|
||||
fetchPrograms(channels, range)
|
||||
val guideStart = guideTimes.value!!.first()
|
||||
fetchPrograms(guideStart, channels, range)
|
||||
loading.setValueOnMain(LoadingState.Success)
|
||||
}
|
||||
|
||||
private suspend fun fetchPrograms(
|
||||
guideStart: LocalDateTime,
|
||||
channels: List<TvChannel>,
|
||||
range: IntRange,
|
||||
) = mutex.withLock {
|
||||
|
|
@ -148,6 +203,7 @@ class LiveTvViewModel
|
|||
channelIds = channelsToFetch.map { it.id },
|
||||
sortBy = listOf(ItemSortBy.START_DATE),
|
||||
userId = serverRepository.currentUser.value?.id,
|
||||
fields = listOf(ItemFields.OVERVIEW),
|
||||
)
|
||||
val fetchedPrograms =
|
||||
api.liveTvApi
|
||||
|
|
@ -173,6 +229,12 @@ class LiveTvViewModel
|
|||
} else {
|
||||
null
|
||||
}
|
||||
// Clean up name/subtitles by collapsing whitespace (including newlines) into single spaces
|
||||
val name = (dto.seriesName ?: dto.name)?.replace(Regex("\\s+"), " ")
|
||||
val subtitle =
|
||||
dto.episodeTitle
|
||||
.takeIf { dto.isSeries ?: false }
|
||||
?.replace(Regex("\\s+"), " ")
|
||||
val p =
|
||||
TvProgram(
|
||||
id = dto.id,
|
||||
|
|
@ -186,8 +248,10 @@ class LiveTvViewModel
|
|||
).coerceAtLeast(0f),
|
||||
endHours = hoursBetween(guideStart, dto.endDate!!),
|
||||
duration = dto.runTimeTicks!!.ticks,
|
||||
name = dto.seriesName ?: dto.name,
|
||||
subtitle = dto.episodeTitle.takeIf { dto.isSeries ?: false },
|
||||
name = name,
|
||||
subtitle = subtitle,
|
||||
overview = dto.overview,
|
||||
officialRating = dto.officialRating,
|
||||
seasonEpisode =
|
||||
if (dto.indexNumber != null && dto.parentIndexNumber != null) {
|
||||
SeasonEpisode(
|
||||
|
|
@ -474,11 +538,13 @@ data class TvProgram(
|
|||
val duration: Duration,
|
||||
val name: String?,
|
||||
val subtitle: String?,
|
||||
val overview: String? = null,
|
||||
val seasonEpisode: SeasonEpisode?,
|
||||
val isRecording: Boolean,
|
||||
val isSeriesRecording: Boolean,
|
||||
val isRepeat: Boolean,
|
||||
val category: ProgramCategory?,
|
||||
val officialRating: String? = null,
|
||||
) {
|
||||
val isFake = category == ProgramCategory.FAKE
|
||||
|
||||
|
|
@ -500,6 +566,7 @@ data class TvProgram(
|
|||
duration = ((endHours - startHours) * 60).toInt().minutes,
|
||||
name = NO_DATA,
|
||||
subtitle = null,
|
||||
overview = null,
|
||||
seasonEpisode = null,
|
||||
isRecording = false,
|
||||
isSeriesRecording = false,
|
||||
|
|
@ -524,3 +591,8 @@ data class FetchedPrograms(
|
|||
val programs: List<TvProgram>,
|
||||
val programsByChannel: Map<UUID, List<TvProgram>>,
|
||||
)
|
||||
|
||||
fun LocalDateTime.roundDownToHalfHour(): LocalDateTime {
|
||||
val min = minute % 30L
|
||||
return minusMinutes(min).truncatedTo(ChronoUnit.MINUTES)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,10 +2,13 @@ package com.github.damontecres.wholphin.ui.detail.livetv
|
|||
|
||||
import android.text.format.DateUtils
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
|
|
@ -13,6 +16,7 @@ import androidx.compose.foundation.shape.CircleShape
|
|||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
|
@ -24,6 +28,7 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.input.key.Key
|
||||
|
|
@ -39,10 +44,15 @@ import androidx.tv.material3.MaterialTheme
|
|||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.SurfaceDefaults
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.LiveTvPreferences
|
||||
import com.github.damontecres.wholphin.ui.components.CircularProgress
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.rememberPosition
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
|
|
@ -66,16 +76,21 @@ fun TvGuideGrid(
|
|||
modifier: Modifier = Modifier,
|
||||
viewModel: LiveTvViewModel = hiltViewModel(),
|
||||
) {
|
||||
var firstLoad by rememberSaveable { mutableStateOf(true) }
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.init(firstLoad)
|
||||
firstLoad = false
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
// var firstLoad by rememberSaveable { mutableStateOf(true) }
|
||||
// LaunchedEffect(Unit) {
|
||||
// viewModel.init(firstLoad)
|
||||
// firstLoad = false
|
||||
// }
|
||||
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
|
||||
val channels by viewModel.channels.observeAsState(listOf())
|
||||
val programs by viewModel.programs.observeAsState(FetchedPrograms(0..0, listOf(), mapOf()))
|
||||
// val programsByChannel by viewModel.programsByChannel.observeAsState(mapOf())
|
||||
// val fetchedRange by viewModel.fetchedRange.observeAsState(0..0)
|
||||
val preferences by viewModel.preferences.data
|
||||
.collectAsState(AppPreferences.getDefaultInstance())
|
||||
val tvPrefs = preferences.interfacePreferences.liveTvPreferences
|
||||
var showViewOptions by remember { mutableStateOf(false) }
|
||||
when (val state = loading) {
|
||||
is LoadingState.Error -> {
|
||||
ErrorMessage(state, modifier)
|
||||
|
|
@ -90,25 +105,59 @@ fun TvGuideGrid(
|
|||
LoadingState.Success,
|
||||
-> {
|
||||
val context = LocalContext.current
|
||||
val guideTimes by viewModel.guideTimes.observeAsState(listOf())
|
||||
val fetchedItem by viewModel.fetchedItem.observeAsState(null)
|
||||
val loadingItem by viewModel.fetchingItem.observeAsState(LoadingState.Pending)
|
||||
var showItemDialog by remember { mutableStateOf<Int?>(null) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val buttonFocusRequester = remember { FocusRequester() }
|
||||
if (requestFocusAfterLoading) {
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.tryRequestFocus()
|
||||
}
|
||||
}
|
||||
Column(modifier = modifier) {
|
||||
var focusedPosition by rememberPosition(0, 0)
|
||||
val focusedProgram =
|
||||
remember(focusedPosition) {
|
||||
focusedPosition.let {
|
||||
val channelId = channels.getOrNull(it.row)?.id
|
||||
programs.programsByChannel[channelId]?.getOrNull(it.column)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
if (channels.isEmpty()) {
|
||||
ErrorMessage("Live TV is enabled, but no channels were found.", null)
|
||||
} else {
|
||||
AnimatedVisibility(tvPrefs.showHeader) {
|
||||
TvGuideHeader(
|
||||
program = focusedProgram,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(top = 24.dp, bottom = 16.dp, start = 32.dp)
|
||||
.fillMaxHeight(.30f),
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(focusedPosition.row < 1) {
|
||||
ExpandableFaButton(
|
||||
title = R.string.view_options,
|
||||
iconStringRes = R.string.fa_sliders,
|
||||
onClick = { showViewOptions = true },
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = tvGuideDimensions.channelWidth)
|
||||
.focusRequester(buttonFocusRequester),
|
||||
)
|
||||
}
|
||||
TvGuideGridContent(
|
||||
preferences = tvPrefs,
|
||||
loading = state is LoadingState.Loading,
|
||||
channels = channels,
|
||||
programs = programs,
|
||||
channelProgramCount = viewModel.channelProgramCount,
|
||||
start = viewModel.guideStart,
|
||||
guideTimes = guideTimes,
|
||||
onClickChannel = { index, channel ->
|
||||
viewModel.navigationManager.navigateTo(
|
||||
Destination.Playback(
|
||||
|
|
@ -118,6 +167,7 @@ fun TvGuideGrid(
|
|||
)
|
||||
},
|
||||
onFocus = {
|
||||
focusedPosition = it
|
||||
onRowPosition.invoke(it.row)
|
||||
viewModel.onFocusChannel(it)
|
||||
},
|
||||
|
|
@ -144,6 +194,7 @@ fun TvGuideGrid(
|
|||
showItemDialog = index
|
||||
}
|
||||
},
|
||||
buttonFocusRequester = buttonFocusRequester,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
|
|
@ -192,24 +243,49 @@ fun TvGuideGrid(
|
|||
}
|
||||
}
|
||||
}
|
||||
if (showViewOptions) {
|
||||
LiveTvViewOptionsDialog(
|
||||
preferences = preferences,
|
||||
onDismissRequest = { showViewOptions = false },
|
||||
onViewOptionsChange = { newPrefs ->
|
||||
scope.launchIO {
|
||||
viewModel.preferences.updateData {
|
||||
newPrefs
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val tvGuideDimensions =
|
||||
ProgramGuideDimensions(
|
||||
timelineHourWidth = 240.dp,
|
||||
timelineHeight = 32.dp,
|
||||
channelWidth = 120.dp,
|
||||
channelHeight = 64.dp,
|
||||
currentTimeWidth = 2.dp,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun TvGuideGridContent(
|
||||
preferences: LiveTvPreferences,
|
||||
loading: Boolean,
|
||||
channels: List<TvChannel>,
|
||||
programs: FetchedPrograms,
|
||||
channelProgramCount: Map<UUID, Int>,
|
||||
start: LocalDateTime,
|
||||
guideTimes: List<LocalDateTime>,
|
||||
onClickChannel: (Int, TvChannel) -> Unit,
|
||||
onClickProgram: (Int, TvProgram) -> Unit,
|
||||
onFocus: (RowColumn) -> Unit,
|
||||
buttonFocusRequester: FocusRequester,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val state = rememberSaveableProgramGuideState()
|
||||
val scope = rememberCoroutineScope()
|
||||
val guideStart = guideTimes.first()
|
||||
|
||||
var focusedItem by rememberPosition(RowColumn(0, 0))
|
||||
val focusedChannelIndex = focusedItem.row
|
||||
|
|
@ -229,25 +305,19 @@ fun TvGuideGridContent(
|
|||
state.animateToProgram(focusedProgramIndex, Alignment.Center)
|
||||
}
|
||||
}
|
||||
val dimensions =
|
||||
ProgramGuideDimensions(
|
||||
timelineHourWidth = 240.dp,
|
||||
timelineHeight = 32.dp,
|
||||
channelWidth = 120.dp,
|
||||
channelHeight = 64.dp,
|
||||
currentTimeWidth = 2.dp,
|
||||
)
|
||||
var gridHasFocus by rememberSaveable { mutableStateOf(false) }
|
||||
var channelColumnFocused by rememberSaveable { mutableStateOf(false) }
|
||||
Box(modifier = modifier) {
|
||||
ProgramGuide(
|
||||
state = state,
|
||||
dimensions = dimensions,
|
||||
dimensions = tvGuideDimensions,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.onFocusChanged {
|
||||
gridHasFocus = it.hasFocus
|
||||
}.focusProperties {
|
||||
up = buttonFocusRequester
|
||||
}.focusable()
|
||||
.onPreviewKeyEvent {
|
||||
if (it.type == KeyEventType.KeyUp) {
|
||||
|
|
@ -291,7 +361,7 @@ fun TvGuideGridContent(
|
|||
|
||||
Key.DirectionUp -> {
|
||||
if (item.row <= 0) {
|
||||
focusManager.moveFocus(FocusDirection.Up)
|
||||
// focusManager.moveFocus(FocusDirection.Up)
|
||||
null
|
||||
} else {
|
||||
val newChannelIndex = item.row - 1
|
||||
|
|
@ -438,7 +508,7 @@ fun TvGuideGridContent(
|
|||
currentTime(
|
||||
layoutInfo = {
|
||||
ProgramGuideItem.CurrentTime(
|
||||
hoursBetween(start, LocalDateTime.now()),
|
||||
hoursBetween(guideStart, LocalDateTime.now()),
|
||||
)
|
||||
},
|
||||
) {
|
||||
|
|
@ -455,11 +525,18 @@ fun TvGuideGridContent(
|
|||
}
|
||||
}
|
||||
timeline(
|
||||
count = MAX_HOURS.toInt(),
|
||||
count = guideTimes.size,
|
||||
layoutInfo = { index ->
|
||||
val start = guideTimes[index]
|
||||
val end =
|
||||
if (index < guideTimes.lastIndex) {
|
||||
guideTimes[index + 1]
|
||||
} else {
|
||||
start.plusHours(1)
|
||||
}
|
||||
ProgramGuideItem.Timeline(
|
||||
startHour = index.toFloat(),
|
||||
endHour = index + 1f,
|
||||
startHour = hoursBetween(guideStart, start),
|
||||
endHour = hoursBetween(guideStart, end),
|
||||
)
|
||||
},
|
||||
) { index ->
|
||||
|
|
@ -477,16 +554,12 @@ fun TvGuideGridContent(
|
|||
shape = RoundedCornerShape(4.dp),
|
||||
),
|
||||
) {
|
||||
val differentDay =
|
||||
start.toLocalDate() !=
|
||||
start
|
||||
.plusHours(index.toLong())
|
||||
.toLocalDate()
|
||||
val guideTime = guideTimes[index]
|
||||
val differentDay = guideTime.toLocalDate() != guideStart.toLocalDate()
|
||||
val time =
|
||||
DateUtils.formatDateTime(
|
||||
context,
|
||||
start
|
||||
.plusHours(index.toLong())
|
||||
guideTime
|
||||
.toInstant(OffsetDateTime.now().offset)
|
||||
.epochSecond * 1000,
|
||||
DateUtils.FORMAT_SHOW_TIME or if (differentDay) DateUtils.FORMAT_SHOW_WEEKDAY else 0,
|
||||
|
|
@ -516,7 +589,7 @@ fun TvGuideGridContent(
|
|||
val program = programs.programs[programIndex]
|
||||
val focused =
|
||||
gridHasFocus && !channelColumnFocused && programIndex == focusedProgramIndex
|
||||
Program(program, focused, Modifier)
|
||||
Program(guideStart, program, focused, preferences.colorCodePrograms, Modifier)
|
||||
}
|
||||
|
||||
channels(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -45,7 +45,6 @@ import com.github.damontecres.wholphin.data.model.Person
|
|||
import com.github.damontecres.wholphin.data.model.RemoteTrailer
|
||||
import com.github.damontecres.wholphin.data.model.Trailer
|
||||
import com.github.damontecres.wholphin.data.model.aspectRatioFloat
|
||||
import com.github.damontecres.wholphin.data.model.chooseSource
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.TrailerService
|
||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||
|
|
@ -55,7 +54,6 @@ import com.github.damontecres.wholphin.ui.cards.ExtrasRow
|
|||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||
import com.github.damontecres.wholphin.ui.cards.PersonRow
|
||||
import com.github.damontecres.wholphin.ui.cards.SeasonCard
|
||||
import com.github.damontecres.wholphin.ui.components.DetailsBackdropImage
|
||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
|
|
@ -83,6 +81,7 @@ import org.jellyfin.sdk.model.api.BaseItemKind
|
|||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import org.jellyfin.sdk.model.serializer.toUUID
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration
|
||||
|
||||
|
|
@ -204,7 +203,7 @@ fun MovieDetails(
|
|||
watched = movie.data.userData?.played ?: false,
|
||||
favorite = movie.data.userData?.isFavorite ?: false,
|
||||
seriesId = null,
|
||||
sourceId = chosenStreams?.sourceId,
|
||||
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
|
||||
actions = moreActions,
|
||||
onChooseVersion = {
|
||||
chooseVersion =
|
||||
|
|
@ -221,25 +220,37 @@ fun MovieDetails(
|
|||
moreDialog = null
|
||||
},
|
||||
onChooseTracks = { type ->
|
||||
chooseSource(
|
||||
movie.data,
|
||||
chosenStreams?.itemPlayback,
|
||||
)?.let { source ->
|
||||
chooseVersion =
|
||||
chooseStream(
|
||||
context = context,
|
||||
streams = source.mediaStreams.orEmpty(),
|
||||
type = type,
|
||||
onClick = { trackIndex ->
|
||||
viewModel.saveTrackSelection(
|
||||
movie,
|
||||
chosenStreams?.itemPlayback,
|
||||
trackIndex,
|
||||
type,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
viewModel.streamChoiceService
|
||||
.chooseSource(
|
||||
movie.data,
|
||||
chosenStreams?.itemPlayback,
|
||||
)?.let { source ->
|
||||
chooseVersion =
|
||||
chooseStream(
|
||||
context = context,
|
||||
streams = source.mediaStreams.orEmpty(),
|
||||
type = type,
|
||||
onClick = { trackIndex ->
|
||||
viewModel.saveTrackSelection(
|
||||
movie,
|
||||
chosenStreams?.itemPlayback,
|
||||
trackIndex,
|
||||
type,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
onShowOverview = {
|
||||
overviewDialog =
|
||||
ItemDetailsDialogInfo(
|
||||
title =
|
||||
movie.name
|
||||
?: context.getString(R.string.unknown),
|
||||
overview = movie.data.overview,
|
||||
genres = movie.data.genres.orEmpty(),
|
||||
files = movie.data.mediaSources.orEmpty(),
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
|
@ -384,7 +395,6 @@ fun MovieDetailsContent(
|
|||
focusRequesters.getOrNull(position)?.tryRequestFocus()
|
||||
}
|
||||
Box(modifier = modifier) {
|
||||
DetailsBackdropImage(movie)
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp),
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import androidx.compose.ui.focus.onFocusChanged
|
|||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -53,6 +54,7 @@ fun MovieDetailsHeader(
|
|||
text = movie.name ?: "",
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(.75f),
|
||||
|
|
@ -70,9 +72,7 @@ fun MovieDetailsHeader(
|
|||
}
|
||||
|
||||
VideoStreamDetails(
|
||||
preferences = preferences,
|
||||
dto = dto,
|
||||
itemPlayback = chosenStreams?.itemPlayback,
|
||||
chosenStreams = chosenStreams,
|
||||
modifier = Modifier.padding(bottom = padding),
|
||||
)
|
||||
dto.taglines?.firstOrNull()?.let { tagline ->
|
||||
|
|
|
|||
|
|
@ -14,12 +14,15 @@ import com.github.damontecres.wholphin.data.model.ItemPlayback
|
|||
import com.github.damontecres.wholphin.data.model.Person
|
||||
import com.github.damontecres.wholphin.data.model.Trailer
|
||||
import com.github.damontecres.wholphin.preferences.ThemeSongVolume
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.ExtrasService
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.PeopleFavorites
|
||||
import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||
import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
||||
import com.github.damontecres.wholphin.services.TrailerService
|
||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
|
|
@ -54,11 +57,14 @@ class MovieViewModel
|
|||
private val navigationManager: NavigationManager,
|
||||
val serverRepository: ServerRepository,
|
||||
val itemPlaybackRepository: ItemPlaybackRepository,
|
||||
val streamChoiceService: StreamChoiceService,
|
||||
private val themeSongPlayer: ThemeSongPlayer,
|
||||
private val favoriteWatchManager: FavoriteWatchManager,
|
||||
private val peopleFavorites: PeopleFavorites,
|
||||
private val trailerService: TrailerService,
|
||||
private val extrasService: ExtrasService,
|
||||
private val userPreferencesService: UserPreferencesService,
|
||||
private val backdropService: BackdropService,
|
||||
@Assisted val itemId: UUID,
|
||||
) : ViewModel() {
|
||||
@AssistedFactory
|
||||
|
|
@ -104,11 +110,17 @@ class MovieViewModel
|
|||
),
|
||||
) {
|
||||
val item = fetchAndSetItem().await()
|
||||
val result = itemPlaybackRepository.getSelectedTracks(item.id, item)
|
||||
val result =
|
||||
itemPlaybackRepository.getSelectedTracks(
|
||||
item.id,
|
||||
item,
|
||||
userPreferencesService.getCurrent(),
|
||||
)
|
||||
withContext(Dispatchers.Main) {
|
||||
this@MovieViewModel.item.value = item
|
||||
chosenStreams.value = result
|
||||
loading.value = LoadingState.Success
|
||||
backdropService.submit(item)
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
val trailers = trailerService.getTrailers(item)
|
||||
|
|
@ -172,10 +184,12 @@ class MovieViewModel
|
|||
sourceId: UUID,
|
||||
) {
|
||||
viewModelScope.launchIO {
|
||||
val prefs = userPreferencesService.getCurrent()
|
||||
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
||||
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
|
||||
val chosen =
|
||||
result?.let {
|
||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result)
|
||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
chosenStreams.value = chosen
|
||||
|
|
@ -190,6 +204,8 @@ class MovieViewModel
|
|||
type: MediaStreamType,
|
||||
) {
|
||||
viewModelScope.launchIO {
|
||||
val prefs = userPreferencesService.getCurrent()
|
||||
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
||||
val result =
|
||||
itemPlaybackRepository.saveTrackSelection(
|
||||
item = item,
|
||||
|
|
@ -199,7 +215,7 @@ class MovieViewModel
|
|||
)
|
||||
val chosen =
|
||||
result?.let {
|
||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result)
|
||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
chosenStreams.value = chosen
|
||||
|
|
|
|||
|
|
@ -37,9 +37,7 @@ fun FocusedEpisodeHeader(
|
|||
|
||||
if (dto != null) {
|
||||
VideoStreamDetails(
|
||||
preferences = preferences,
|
||||
dto = dto,
|
||||
itemPlayback = chosenStreams?.itemPlayback,
|
||||
chosenStreams = chosenStreams,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,7 +51,6 @@ import com.github.damontecres.wholphin.ui.cards.ItemRow
|
|||
import com.github.damontecres.wholphin.ui.cards.PersonRow
|
||||
import com.github.damontecres.wholphin.ui.cards.SeasonCard
|
||||
import com.github.damontecres.wholphin.ui.components.ConfirmDialog
|
||||
import com.github.damontecres.wholphin.ui.components.DetailsBackdropImage
|
||||
import com.github.damontecres.wholphin.ui.components.DialogItem
|
||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||
|
|
@ -320,8 +319,6 @@ fun SeriesDetailsContent(
|
|||
Box(
|
||||
modifier = modifier,
|
||||
) {
|
||||
DetailsBackdropImage(series)
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import androidx.lifecycle.compose.LifecycleStartEffect
|
|||
import androidx.lifecycle.map
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.chooseSource
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||
|
|
@ -47,12 +46,12 @@ import com.github.damontecres.wholphin.util.LoadingState
|
|||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.UseSerializers
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import org.jellyfin.sdk.model.api.PersonKind
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
import org.jellyfin.sdk.model.serializer.toUUID
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration
|
||||
|
|
@ -219,7 +218,7 @@ fun SeriesOverview(
|
|||
watched = ep.data.userData?.played ?: false,
|
||||
favorite = ep.data.userData?.isFavorite ?: false,
|
||||
seriesId = series.id,
|
||||
sourceId = chosenStreams?.sourceId,
|
||||
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
|
||||
actions =
|
||||
MoreDialogActions(
|
||||
navigateTo = viewModel::navigateTo,
|
||||
|
|
@ -257,25 +256,35 @@ fun SeriesOverview(
|
|||
moreDialog = null
|
||||
},
|
||||
onChooseTracks = { type ->
|
||||
chooseSource(
|
||||
ep.data,
|
||||
chosenStreams?.itemPlayback,
|
||||
)?.let { source ->
|
||||
chooseVersion =
|
||||
chooseStream(
|
||||
context = context,
|
||||
streams = source.mediaStreams.orEmpty(),
|
||||
type = type,
|
||||
onClick = { trackIndex ->
|
||||
viewModel.saveTrackSelection(
|
||||
ep,
|
||||
chosenStreams?.itemPlayback,
|
||||
trackIndex,
|
||||
type,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
viewModel.streamChoiceService
|
||||
.chooseSource(
|
||||
ep.data,
|
||||
chosenStreams?.itemPlayback,
|
||||
)?.let { source ->
|
||||
chooseVersion =
|
||||
chooseStream(
|
||||
context = context,
|
||||
streams = source.mediaStreams.orEmpty(),
|
||||
type = type,
|
||||
onClick = { trackIndex ->
|
||||
viewModel.saveTrackSelection(
|
||||
ep,
|
||||
chosenStreams?.itemPlayback,
|
||||
trackIndex,
|
||||
type,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
onShowOverview = {
|
||||
overviewDialog =
|
||||
ItemDetailsDialogInfo(
|
||||
title = ep.name ?: context.getString(R.string.unknown),
|
||||
overview = ep.data.overview,
|
||||
genres = ep.data.genres.orEmpty(),
|
||||
files = ep.data.mediaSources.orEmpty(),
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
|
@ -288,13 +297,6 @@ fun SeriesOverview(
|
|||
chosenStreams = chosenStreams,
|
||||
peopleInEpisode = peopleInEpisode,
|
||||
position = position,
|
||||
backdropImageUrl =
|
||||
remember {
|
||||
viewModel.imageUrl(
|
||||
series.id,
|
||||
ImageType.BACKDROP,
|
||||
)
|
||||
},
|
||||
firstItemFocusRequester = firstItemFocusRequester,
|
||||
episodeRowFocusRequester = episodeRowFocusRequester,
|
||||
castCrewRowFocusRequester = castCrewRowFocusRequester,
|
||||
|
|
|
|||
|
|
@ -52,7 +52,6 @@ import com.github.damontecres.wholphin.ui.AspectRatios
|
|||
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
||||
import com.github.damontecres.wholphin.ui.cards.BannerCard
|
||||
import com.github.damontecres.wholphin.ui.cards.PersonRow
|
||||
import com.github.damontecres.wholphin.ui.components.DetailsBackdropImage
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.components.SeriesName
|
||||
|
|
@ -75,7 +74,6 @@ fun SeriesOverviewContent(
|
|||
chosenStreams: ChosenStreams?,
|
||||
peopleInEpisode: List<Person>,
|
||||
position: SeriesOverviewPosition,
|
||||
backdropImageUrl: String?,
|
||||
firstItemFocusRequester: FocusRequester,
|
||||
episodeRowFocusRequester: FocusRequester,
|
||||
castCrewRowFocusRequester: FocusRequester,
|
||||
|
|
@ -112,7 +110,6 @@ fun SeriesOverviewContent(
|
|||
modifier
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
DetailsBackdropImage(backdropImageUrl)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier =
|
||||
|
|
@ -218,9 +215,7 @@ fun SeriesOverviewContent(
|
|||
item = episode,
|
||||
aspectRatio =
|
||||
episode
|
||||
?.data
|
||||
?.primaryImageAspectRatio
|
||||
?.toFloat()
|
||||
?.aspectRatio
|
||||
?.coerceAtLeast(AspectRatios.FOUR_THREE)
|
||||
?: (AspectRatios.WIDE),
|
||||
cornerText = cornerText,
|
||||
|
|
|
|||
|
|
@ -14,12 +14,15 @@ import com.github.damontecres.wholphin.data.model.Person
|
|||
import com.github.damontecres.wholphin.data.model.Trailer
|
||||
import com.github.damontecres.wholphin.preferences.ThemeSongVolume
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.ExtrasService
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.PeopleFavorites
|
||||
import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||
import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
||||
import com.github.damontecres.wholphin.services.TrailerService
|
||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
import com.github.damontecres.wholphin.ui.detail.ItemViewModel
|
||||
import com.github.damontecres.wholphin.ui.equalsNotNull
|
||||
|
|
@ -70,6 +73,9 @@ class SeriesViewModel
|
|||
private val peopleFavorites: PeopleFavorites,
|
||||
private val trailerService: TrailerService,
|
||||
private val extrasService: ExtrasService,
|
||||
val streamChoiceService: StreamChoiceService,
|
||||
private val userPreferencesService: UserPreferencesService,
|
||||
private val backdropService: BackdropService,
|
||||
) : ItemViewModel(api) {
|
||||
private lateinit var seriesId: UUID
|
||||
private lateinit var prefs: UserPreferences
|
||||
|
|
@ -99,6 +105,7 @@ class SeriesViewModel
|
|||
) + Dispatchers.IO,
|
||||
) {
|
||||
val item = fetchItem(seriesId)
|
||||
backdropService.submit(item)
|
||||
val seasons = getSeasons(item)
|
||||
|
||||
// If a particular season was requested, fetch those episodes, otherwise get the first season
|
||||
|
|
@ -328,6 +335,8 @@ class SeriesViewModel
|
|||
episodes.value = eps
|
||||
}
|
||||
}
|
||||
// Kind of hack to ensure the backdrop is reloaded if needed
|
||||
item.value?.let { backdropService.submit(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -372,7 +381,12 @@ class SeriesViewModel
|
|||
chosenStreamsJob?.cancel()
|
||||
chosenStreamsJob =
|
||||
viewModelScope.launchIO {
|
||||
val result = itemPlaybackRepository.getSelectedTracks(itemId, item)
|
||||
val result =
|
||||
itemPlaybackRepository.getSelectedTracks(
|
||||
itemId,
|
||||
item,
|
||||
userPreferencesService.getCurrent(),
|
||||
)
|
||||
withContext(Dispatchers.Main) {
|
||||
chosenStreams.value = result
|
||||
}
|
||||
|
|
@ -384,10 +398,12 @@ class SeriesViewModel
|
|||
sourceId: UUID,
|
||||
) {
|
||||
viewModelScope.launchIO {
|
||||
val prefs = userPreferencesService.getCurrent()
|
||||
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
||||
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
|
||||
val chosen =
|
||||
result?.let {
|
||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result)
|
||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
chosenStreams.value = chosen
|
||||
|
|
@ -402,6 +418,8 @@ class SeriesViewModel
|
|||
type: MediaStreamType,
|
||||
) {
|
||||
viewModelScope.launchIO {
|
||||
val prefs = userPreferencesService.getCurrent()
|
||||
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
||||
val result =
|
||||
itemPlaybackRepository.saveTrackSelection(
|
||||
item = item,
|
||||
|
|
@ -411,7 +429,7 @@ class SeriesViewModel
|
|||
)
|
||||
val chosen =
|
||||
result?.let {
|
||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result)
|
||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
chosenStreams.value = chosen
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import androidx.compose.ui.focus.focusRequester
|
|||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
|
|
@ -50,7 +51,6 @@ import com.github.damontecres.wholphin.ui.Cards
|
|||
import com.github.damontecres.wholphin.ui.cards.BannerCard
|
||||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||
import com.github.damontecres.wholphin.ui.components.CircularProgress
|
||||
import com.github.damontecres.wholphin.ui.components.DelayedDetailsBackdropImage
|
||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||
import com.github.damontecres.wholphin.ui.components.EpisodeQuickDetails
|
||||
|
|
@ -67,12 +67,16 @@ import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
|||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp
|
||||
import com.github.damontecres.wholphin.ui.playback.playable
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import kotlinx.coroutines.delay
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
|
||||
@Composable
|
||||
|
|
@ -157,8 +161,12 @@ fun HomePage(
|
|||
items = dialogItems,
|
||||
)
|
||||
},
|
||||
onClickPlay = { _, item ->
|
||||
viewModel.navigationManager.navigateTo(Destination.Playback(item))
|
||||
},
|
||||
loadingState = refreshing,
|
||||
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
||||
onUpdateBackdrop = viewModel::updateBackdrop,
|
||||
modifier = modifier,
|
||||
)
|
||||
dialog?.let { params ->
|
||||
|
|
@ -193,7 +201,9 @@ fun HomePageContent(
|
|||
homeRows: List<HomeRowLoadingState>,
|
||||
onClickItem: (RowColumn, BaseItem) -> Unit,
|
||||
onLongClickItem: (RowColumn, BaseItem) -> Unit,
|
||||
onClickPlay: (RowColumn, BaseItem) -> Unit,
|
||||
showClock: Boolean,
|
||||
onUpdateBackdrop: (BaseItem) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onFocusPosition: ((RowColumn) -> Unit)? = null,
|
||||
loadingState: LoadingState? = null,
|
||||
|
|
@ -240,12 +250,10 @@ fun HomePageContent(
|
|||
LaunchedEffect(position) {
|
||||
listState.animateScrollToItem(position.row)
|
||||
}
|
||||
LaunchedEffect(focusedItem) {
|
||||
focusedItem?.let(onUpdateBackdrop)
|
||||
}
|
||||
Box(modifier = modifier) {
|
||||
DelayedDetailsBackdropImage(
|
||||
item = focusedItem,
|
||||
modifier = Modifier,
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
HomePageHeader(
|
||||
item = focusedItem,
|
||||
|
|
@ -264,7 +272,18 @@ fun HomePageContent(
|
|||
top = 0.dp,
|
||||
bottom = Cards.height2x3,
|
||||
),
|
||||
modifier = Modifier.focusRestorer(),
|
||||
modifier =
|
||||
Modifier
|
||||
.focusRestorer()
|
||||
.onKeyEvent {
|
||||
val item = focusedItem
|
||||
if (isPlayKeyUp(it) && item?.type?.playable == true) {
|
||||
Timber.v("Clicked play on ${item.id}")
|
||||
onClickPlay.invoke(position, item)
|
||||
return@onKeyEvent true
|
||||
}
|
||||
return@onKeyEvent false
|
||||
},
|
||||
) {
|
||||
itemsIndexed(homeRows) { rowIndex, row ->
|
||||
when (val r = row) {
|
||||
|
|
|
|||
|
|
@ -9,10 +9,12 @@ import com.github.damontecres.wholphin.data.NavDrawerItemRepository
|
|||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.DatePlayedService
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
|
|
@ -58,6 +60,7 @@ class HomeViewModel
|
|||
val navDrawerItemRepository: NavDrawerItemRepository,
|
||||
private val favoriteWatchManager: FavoriteWatchManager,
|
||||
private val datePlayedService: DatePlayedService,
|
||||
private val backdropService: BackdropService,
|
||||
) : ViewModel() {
|
||||
val loadingState = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||
val refreshState = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||
|
|
@ -316,6 +319,12 @@ class HomeViewModel
|
|||
init(preferences)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateBackdrop(item: BaseItem) {
|
||||
viewModelScope.launchIO {
|
||||
backdropService.submit(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val supportedLatestCollectionTypes =
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -196,7 +195,8 @@ fun SearchPage(
|
|||
val episodes by viewModel.episodes.observeAsState(SearchResult.NoQuery)
|
||||
val seerrResults by viewModel.seerrResults.observeAsState(SearchResult.NoQuery)
|
||||
|
||||
val query = rememberTextFieldState()
|
||||
// val query = rememberTextFieldState()
|
||||
var query by rememberSaveable { mutableStateOf("") }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
var position by rememberPosition()
|
||||
|
|
@ -204,7 +204,7 @@ fun SearchPage(
|
|||
|
||||
LaunchedEffect(query) {
|
||||
delay(750L)
|
||||
viewModel.search(query.text.toString())
|
||||
viewModel.search(query)
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.tryRequestFocus()
|
||||
|
|
@ -238,9 +238,10 @@ fun SearchPage(
|
|||
focusManager.moveFocus(FocusDirection.Next)
|
||||
}
|
||||
SearchEditTextBox(
|
||||
state = query,
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
onSearchClick = {
|
||||
viewModel.search(query.text.toString())
|
||||
viewModel.search(query)
|
||||
searchClicked = true
|
||||
},
|
||||
modifier =
|
||||
|
|
|
|||
|
|
@ -1,17 +1,59 @@
|
|||
package com.github.damontecres.wholphin.ui.nav
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||
import androidx.navigation3.runtime.NavEntry
|
||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.transitionFactory
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.preferences.BackdropStyle
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.ui.CrossFadeFactory
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@HiltViewModel
|
||||
class ApplicationContentViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
val backdropService: BackdropService,
|
||||
) : ViewModel() {
|
||||
fun clearBackdrop() {
|
||||
viewModelScope.launchIO { backdropService.clearBackdrop() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This is generally the root composable of the of the app
|
||||
|
|
@ -25,37 +67,158 @@ fun ApplicationContent(
|
|||
navigationManager: NavigationManager,
|
||||
preferences: UserPreferences,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: ApplicationContentViewModel = hiltViewModel(),
|
||||
) {
|
||||
NavDisplay(
|
||||
backStack = navigationManager.backStack,
|
||||
onBack = { navigationManager.goBack() },
|
||||
entryDecorators =
|
||||
listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator(),
|
||||
),
|
||||
entryProvider = { key ->
|
||||
key as Destination
|
||||
val contentKey = "${key}_${server?.id}_${user?.id}"
|
||||
NavEntry(key, contentKey = contentKey) {
|
||||
if (key.fullScreen) {
|
||||
DestinationContent(
|
||||
destination = key,
|
||||
preferences = preferences,
|
||||
modifier = modifier.fillMaxSize(),
|
||||
)
|
||||
} else if (user != null && server != null) {
|
||||
NavDrawer(
|
||||
destination = key,
|
||||
preferences = preferences,
|
||||
user = user,
|
||||
server = server,
|
||||
modifier = modifier,
|
||||
)
|
||||
} else {
|
||||
ErrorMessage("Trying to go to $key without a user logged in", null)
|
||||
}
|
||||
val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle()
|
||||
val backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle
|
||||
Box(
|
||||
modifier = modifier,
|
||||
) {
|
||||
val baseBackgroundColor = MaterialTheme.colorScheme.background
|
||||
if (backdrop.hasColors &&
|
||||
(backdropStyle == BackdropStyle.BACKDROP_DYNAMIC_COLOR || backdropStyle == BackdropStyle.UNRECOGNIZED)
|
||||
) {
|
||||
val animPrimary by animateColorAsState(
|
||||
backdrop.primaryColor,
|
||||
animationSpec = tween(1250),
|
||||
label = "dynamic_backdrop_primary",
|
||||
)
|
||||
val animSecondary by animateColorAsState(
|
||||
backdrop.secondaryColor,
|
||||
animationSpec = tween(1250),
|
||||
label = "dynamic_backdrop_secondary",
|
||||
)
|
||||
val animTertiary by animateColorAsState(
|
||||
backdrop.tertiaryColor,
|
||||
animationSpec = tween(1250),
|
||||
label = "dynamic_backdrop_tertiary",
|
||||
)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.drawBehind {
|
||||
drawRect(color = baseBackgroundColor)
|
||||
// Top Left (Vibrant/Muted)
|
||||
drawRect(
|
||||
brush =
|
||||
Brush.radialGradient(
|
||||
colors = listOf(animSecondary, Color.Transparent),
|
||||
center = Offset(0f, 0f),
|
||||
radius = size.width * 0.8f,
|
||||
),
|
||||
)
|
||||
// Bottom Right (DarkVibrant/DarkMuted)
|
||||
drawRect(
|
||||
brush =
|
||||
Brush.radialGradient(
|
||||
colors = listOf(animPrimary, Color.Transparent),
|
||||
center = Offset(size.width, size.height),
|
||||
radius = size.width * 0.8f,
|
||||
),
|
||||
)
|
||||
// Bottom Left (Dark / Bridge)
|
||||
drawRect(
|
||||
brush =
|
||||
Brush.radialGradient(
|
||||
colors =
|
||||
listOf(
|
||||
baseBackgroundColor,
|
||||
Color.Transparent,
|
||||
),
|
||||
center = Offset(0f, size.height),
|
||||
radius = size.width * 0.8f,
|
||||
),
|
||||
)
|
||||
// Top Right (Under Image - Vibrant/Bright)
|
||||
drawRect(
|
||||
brush =
|
||||
Brush.radialGradient(
|
||||
colors = listOf(animTertiary, Color.Transparent),
|
||||
center = Offset(size.width, 0f),
|
||||
radius = size.width * 0.8f,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (backdropStyle != BackdropStyle.BACKDROP_NONE) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
AsyncImage(
|
||||
model =
|
||||
ImageRequest
|
||||
.Builder(LocalContext.current)
|
||||
.data(backdrop.imageUrl)
|
||||
.transitionFactory(CrossFadeFactory(800.milliseconds))
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
alignment = Alignment.TopEnd,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.fillMaxHeight(.7f)
|
||||
.fillMaxWidth(.7f)
|
||||
.alpha(.95f)
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
drawRect(
|
||||
brush =
|
||||
Brush.horizontalGradient(
|
||||
colors = listOf(Color.Transparent, Color.Black),
|
||||
startX = 0f,
|
||||
endX = size.width * 0.6f,
|
||||
),
|
||||
blendMode = BlendMode.DstIn,
|
||||
)
|
||||
drawRect(
|
||||
brush =
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(Color.Black, Color.Transparent),
|
||||
startY = 0f,
|
||||
endY = size.height,
|
||||
),
|
||||
blendMode = BlendMode.DstIn,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
NavDisplay(
|
||||
backStack = navigationManager.backStack,
|
||||
onBack = { navigationManager.goBack() },
|
||||
entryDecorators =
|
||||
listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator(),
|
||||
),
|
||||
entryProvider = { key ->
|
||||
key as Destination
|
||||
val contentKey = "${key}_${server?.id}_${user?.id}"
|
||||
NavEntry(key, contentKey = contentKey) {
|
||||
if (key.fullScreen) {
|
||||
DestinationContent(
|
||||
destination = key,
|
||||
preferences = preferences,
|
||||
onClearBackdrop = viewModel::clearBackdrop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else if (user != null && server != null) {
|
||||
NavDrawer(
|
||||
destination = key,
|
||||
preferences = preferences,
|
||||
user = user,
|
||||
server = server,
|
||||
onClearBackdrop = viewModel::clearBackdrop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else {
|
||||
ErrorMessage("Trying to go to $key without a user logged in", null)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.github.damontecres.wholphin.ui.nav
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.data.filter.DefaultForGenresFilterOptions
|
||||
|
|
@ -44,8 +45,12 @@ import timber.log.Timber
|
|||
fun DestinationContent(
|
||||
destination: Destination,
|
||||
preferences: UserPreferences,
|
||||
onClearBackdrop: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (destination.fullScreen) {
|
||||
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||
}
|
||||
when (destination) {
|
||||
is Destination.Home -> {
|
||||
HomePage(
|
||||
|
|
@ -125,6 +130,7 @@ fun DestinationContent(
|
|||
}
|
||||
|
||||
BaseItemKind.BOX_SET -> {
|
||||
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||
CollectionFolderBoxSet(
|
||||
preferences = preferences,
|
||||
itemId = destination.itemId,
|
||||
|
|
@ -136,6 +142,7 @@ fun DestinationContent(
|
|||
}
|
||||
|
||||
BaseItemKind.PLAYLIST -> {
|
||||
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||
PlaylistDetails(
|
||||
destination = destination,
|
||||
modifier = modifier,
|
||||
|
|
@ -143,6 +150,7 @@ fun DestinationContent(
|
|||
}
|
||||
|
||||
BaseItemKind.COLLECTION_FOLDER -> {
|
||||
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||
CollectionFolder(
|
||||
preferences = preferences,
|
||||
destination = destination,
|
||||
|
|
@ -154,6 +162,7 @@ fun DestinationContent(
|
|||
}
|
||||
|
||||
BaseItemKind.FOLDER -> {
|
||||
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||
CollectionFolder(
|
||||
preferences = preferences,
|
||||
destination = destination,
|
||||
|
|
@ -165,6 +174,7 @@ fun DestinationContent(
|
|||
}
|
||||
|
||||
BaseItemKind.USER_VIEW -> {
|
||||
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||
CollectionFolder(
|
||||
preferences = preferences,
|
||||
destination = destination,
|
||||
|
|
@ -176,6 +186,7 @@ fun DestinationContent(
|
|||
}
|
||||
|
||||
BaseItemKind.PERSON -> {
|
||||
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||
PersonPage(
|
||||
preferences,
|
||||
destination,
|
||||
|
|
@ -191,6 +202,7 @@ fun DestinationContent(
|
|||
}
|
||||
|
||||
is Destination.FilteredCollection -> {
|
||||
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||
CollectionFolderGeneric(
|
||||
preferences = preferences,
|
||||
itemId = destination.itemId,
|
||||
|
|
@ -204,6 +216,7 @@ fun DestinationContent(
|
|||
}
|
||||
|
||||
is Destination.Recordings -> {
|
||||
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||
CollectionFolderRecordings(
|
||||
preferences,
|
||||
destination.itemId,
|
||||
|
|
@ -213,6 +226,7 @@ fun DestinationContent(
|
|||
}
|
||||
|
||||
is Destination.ItemGrid -> {
|
||||
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||
ItemGrid(
|
||||
destination,
|
||||
modifier,
|
||||
|
|
@ -220,6 +234,7 @@ fun DestinationContent(
|
|||
}
|
||||
|
||||
Destination.Favorites -> {
|
||||
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||
FavoritesPage(
|
||||
preferences = preferences,
|
||||
modifier = modifier,
|
||||
|
|
@ -235,6 +250,7 @@ fun DestinationContent(
|
|||
}
|
||||
|
||||
Destination.Search -> {
|
||||
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||
SearchPage(
|
||||
userPreferences = preferences,
|
||||
modifier = modifier,
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ import com.github.damontecres.wholphin.data.model.JellyfinServer
|
|||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.preferences.AppThemeColors
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||
import com.github.damontecres.wholphin.ui.components.TimeDisplay
|
||||
|
|
@ -105,6 +106,7 @@ class NavDrawerViewModel
|
|||
constructor(
|
||||
private val navDrawerItemRepository: NavDrawerItemRepository,
|
||||
val navigationManager: NavigationManager,
|
||||
val backdropService: BackdropService,
|
||||
) : ViewModel() {
|
||||
private var all: List<NavDrawerItem>? = null
|
||||
val moreLibraries = MutableLiveData<List<NavDrawerItem>>(null)
|
||||
|
|
@ -217,6 +219,7 @@ fun NavDrawer(
|
|||
preferences: UserPreferences,
|
||||
user: JellyfinUser,
|
||||
server: JellyfinServer,
|
||||
onClearBackdrop: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: NavDrawerViewModel =
|
||||
hiltViewModel(
|
||||
|
|
@ -314,11 +317,9 @@ fun NavDrawer(
|
|||
val drawerPadding by animateDpAsState(if (drawerState.isOpen) 0.dp else 8.dp)
|
||||
val drawerBackground by animateColorAsState(
|
||||
if (drawerState.isOpen) {
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(
|
||||
1.dp,
|
||||
)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
} else {
|
||||
Color.Transparent
|
||||
},
|
||||
)
|
||||
val spacedBy = 4.dp
|
||||
|
|
@ -542,6 +543,7 @@ fun NavDrawer(
|
|||
DestinationContent(
|
||||
destination = destination,
|
||||
preferences = preferences,
|
||||
onClearBackdrop = onClearBackdrop,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize(),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import com.github.damontecres.wholphin.util.TrackSupport
|
|||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import org.jellyfin.sdk.model.api.TranscodingInfo
|
||||
import kotlin.time.Duration
|
||||
|
||||
data class CurrentPlayback(
|
||||
val item: BaseItem,
|
||||
|
|
@ -18,4 +19,5 @@ data class CurrentPlayback(
|
|||
val videoDecoder: String? = null,
|
||||
val audioDecoder: String? = null,
|
||||
val transcodeInfo: TranscodingInfo? = null,
|
||||
val subtitleDelay: Duration = Duration.ZERO,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,14 +11,18 @@ import androidx.compose.foundation.layout.padding
|
|||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
|
|
@ -102,7 +106,8 @@ fun DownloadSubtitlesContent(
|
|||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
val lang = rememberTextFieldState(language)
|
||||
// val lang = rememberTextFieldState(language)
|
||||
var lang by rememberSaveable { mutableStateOf("") }
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
|
@ -113,10 +118,17 @@ fun DownloadSubtitlesContent(
|
|||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
EditTextBox(
|
||||
state = lang,
|
||||
onKeyboardAction = {
|
||||
onSearch(lang.text.toString())
|
||||
},
|
||||
value = lang,
|
||||
onValueChange = { lang = it },
|
||||
keyboardActions =
|
||||
KeyboardActions(
|
||||
onSearch = {
|
||||
onSearch(lang)
|
||||
},
|
||||
),
|
||||
// onKeyboardAction = {
|
||||
// onSearch(lang.text.toString())
|
||||
// },
|
||||
keyboardOptions =
|
||||
KeyboardOptions(
|
||||
imeAction = ImeAction.Search,
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ fun NextUpEpisode(
|
|||
modifier =
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.aspectRatio(aspectRatio)
|
||||
.aspectRatio(aspectRatio.coerceAtLeast(AspectRatios.MIN))
|
||||
// .fillMaxSize()
|
||||
// .weight(1f)
|
||||
.focusRequester(focusRequester),
|
||||
|
|
|
|||
|
|
@ -1,13 +1,26 @@
|
|||
package com.github.damontecres.wholphin.ui.playback
|
||||
|
||||
import android.view.Gravity
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.window.DialogWindowProvider
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||
import com.github.damontecres.wholphin.ui.AppColors
|
||||
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration
|
||||
|
||||
enum class PlaybackDialogType {
|
||||
MORE,
|
||||
|
|
@ -16,6 +29,7 @@ enum class PlaybackDialogType {
|
|||
AUDIO,
|
||||
PLAYBACK_SPEED,
|
||||
VIDEO_SCALE,
|
||||
SUBTITLE_DELAY,
|
||||
}
|
||||
|
||||
data class PlaybackSettings(
|
||||
|
|
@ -26,16 +40,19 @@ data class PlaybackSettings(
|
|||
val subtitleStreams: List<SubtitleStream>,
|
||||
val playbackSpeed: Float,
|
||||
val contentScale: ContentScale,
|
||||
val subtitleDelay: Duration,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun PlaybackDialog(
|
||||
enableSubtitleDelay: Boolean,
|
||||
type: PlaybackDialogType,
|
||||
settings: PlaybackSettings,
|
||||
onDismissRequest: () -> Unit,
|
||||
onControllerInteraction: () -> Unit,
|
||||
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
|
||||
onPlaybackActionClick: (PlaybackAction) -> Unit,
|
||||
onChangeSubtitleDelay: (Duration) -> Unit,
|
||||
) {
|
||||
when (type) {
|
||||
PlaybackDialogType.MORE -> {
|
||||
|
|
@ -97,11 +114,14 @@ fun PlaybackDialog(
|
|||
|
||||
PlaybackDialogType.SETTINGS -> {
|
||||
val options =
|
||||
listOf(
|
||||
stringResource(R.string.audio),
|
||||
stringResource(R.string.playback_speed),
|
||||
stringResource(R.string.video_scale),
|
||||
)
|
||||
buildList {
|
||||
add(stringResource(R.string.audio))
|
||||
add(stringResource(R.string.playback_speed))
|
||||
add(stringResource(R.string.video_scale))
|
||||
if (enableSubtitleDelay) {
|
||||
add(stringResource(R.string.subtitle_delay))
|
||||
}
|
||||
}
|
||||
BottomDialog(
|
||||
choices = options,
|
||||
currentChoice = null,
|
||||
|
|
@ -111,6 +131,7 @@ fun PlaybackDialog(
|
|||
0 -> onClickPlaybackDialogType(PlaybackDialogType.AUDIO)
|
||||
1 -> onClickPlaybackDialogType(PlaybackDialogType.PLAYBACK_SPEED)
|
||||
2 -> onClickPlaybackDialogType(PlaybackDialogType.VIDEO_SCALE)
|
||||
3 -> onClickPlaybackDialogType(PlaybackDialogType.SUBTITLE_DELAY)
|
||||
}
|
||||
},
|
||||
gravity = Gravity.END,
|
||||
|
|
@ -173,5 +194,31 @@ fun PlaybackDialog(
|
|||
gravity = Gravity.END,
|
||||
)
|
||||
}
|
||||
|
||||
PlaybackDialogType.SUBTITLE_DELAY -> {
|
||||
Dialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider
|
||||
dialogWindowProvider?.window?.setDimAmount(0f)
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.wrapContentSize()
|
||||
.background(
|
||||
AppColors.TransparentBlack50,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
),
|
||||
) {
|
||||
SubtitleDelay(
|
||||
delay = settings.subtitleDelay,
|
||||
onChangeDelay = onChangeSubtitleDelay,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.Column
|
|||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
|
|
@ -34,6 +35,8 @@ import androidx.compose.ui.focus.FocusRequester
|
|||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
|
|
@ -127,10 +130,37 @@ fun PlaybackOverlay(
|
|||
var controllerHeight by remember { mutableStateOf(0.dp) }
|
||||
var state by remember { mutableStateOf(OverlayViewState.CONTROLLER) }
|
||||
|
||||
// Background scrim for OSD readability
|
||||
val scrimBrush =
|
||||
remember {
|
||||
Brush.verticalGradient(
|
||||
colors =
|
||||
listOf(
|
||||
Color.Transparent,
|
||||
Color.Black.copy(alpha = 0.5f),
|
||||
Color.Black.copy(alpha = 0.80f),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier,
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = controllerViewState.controlsVisible,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier.matchParentSize(),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(scrimBrush),
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
state == OverlayViewState.CONTROLLER,
|
||||
enter = slideInVertically() + fadeIn(),
|
||||
|
|
@ -398,17 +428,13 @@ fun PlaybackOverlay(
|
|||
val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight
|
||||
val index =
|
||||
(seekProgressMs / trickplayInfo.interval).toInt() / tilesPerImage
|
||||
val imageUrl = trickplayUrlFor(index)
|
||||
val imageUrl = remember(index) { trickplayUrlFor(index) }
|
||||
|
||||
if (imageUrl != null) {
|
||||
SeekPreviewImage(
|
||||
modifier =
|
||||
Modifier,
|
||||
modifier = Modifier,
|
||||
previewImageUrl = imageUrl,
|
||||
duration = playerControls.duration,
|
||||
seekProgressMs = seekProgressMs,
|
||||
videoWidth = trickplayInfo.width,
|
||||
videoHeight = trickplayInfo.height,
|
||||
trickPlayInfo = trickplayInfo,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,11 +79,13 @@ import com.github.damontecres.wholphin.ui.tryRequestFocus
|
|||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import com.github.damontecres.wholphin.util.Media3SubtitleOverride
|
||||
import com.github.damontecres.wholphin.util.mpv.MpvPlayer
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
|
|
@ -163,6 +165,11 @@ fun PlaybackPage(
|
|||
var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
|
||||
LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) }
|
||||
|
||||
val subtitleDelay = currentPlayback?.subtitleDelay ?: Duration.ZERO
|
||||
LaunchedEffect(subtitleDelay) {
|
||||
(player as? MpvPlayer)?.subtitleDelay = subtitleDelay
|
||||
}
|
||||
|
||||
val presentationState = rememberPresentationState(player, false)
|
||||
val scaledModifier =
|
||||
Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp)
|
||||
|
|
@ -325,37 +332,6 @@ fun PlaybackPage(
|
|||
}
|
||||
}
|
||||
|
||||
// Subtitles
|
||||
if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) {
|
||||
val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f)
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
SubtitleView(context).apply {
|
||||
preferences.appPreferences.interfacePreferences.subtitlesPreferences.let {
|
||||
setStyle(it.toSubtitleStyle())
|
||||
setFixedTextSize(Dimension.SP, it.fontSize.toFloat())
|
||||
setBottomPaddingFraction(it.margin.toFloat() / 100f)
|
||||
}
|
||||
}
|
||||
},
|
||||
update = {
|
||||
it.setCues(cues)
|
||||
Media3SubtitleOverride(
|
||||
preferences.appPreferences.interfacePreferences.subtitlesPreferences
|
||||
.calculateEdgeSize(density),
|
||||
).apply(it)
|
||||
},
|
||||
onReset = {
|
||||
it.setCues(null)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize(maxSize)
|
||||
.align(Alignment.TopCenter)
|
||||
.background(Color.Transparent),
|
||||
)
|
||||
}
|
||||
|
||||
// The playback controls
|
||||
AnimatedVisibility(
|
||||
controllerViewState.controlsVisible,
|
||||
|
|
@ -395,6 +371,37 @@ fun PlaybackPage(
|
|||
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
||||
)
|
||||
}
|
||||
|
||||
// Subtitles
|
||||
if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) {
|
||||
val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f)
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
SubtitleView(context).apply {
|
||||
preferences.appPreferences.interfacePreferences.subtitlesPreferences.let {
|
||||
setStyle(it.toSubtitleStyle())
|
||||
setFixedTextSize(Dimension.SP, it.fontSize.toFloat())
|
||||
setBottomPaddingFraction(it.margin.toFloat() / 100f)
|
||||
}
|
||||
}
|
||||
},
|
||||
update = {
|
||||
it.setCues(cues)
|
||||
Media3SubtitleOverride(
|
||||
preferences.appPreferences.interfacePreferences.subtitlesPreferences
|
||||
.calculateEdgeSize(density),
|
||||
).apply(it)
|
||||
},
|
||||
onReset = {
|
||||
it.setCues(null)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize(maxSize)
|
||||
.align(Alignment.TopCenter)
|
||||
.background(Color.Transparent),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Ask to skip intros, etc button
|
||||
|
|
@ -473,9 +480,7 @@ fun PlaybackPage(
|
|||
).joinToString(" - "),
|
||||
description = it.data.overview,
|
||||
imageUrl = LocalImageUrlService.current.rememberImageUrl(it),
|
||||
aspectRatio =
|
||||
it.data.primaryImageAspectRatio?.toFloat()
|
||||
?: AspectRatios.WIDE,
|
||||
aspectRatio = it.aspectRatio ?: AspectRatios.WIDE,
|
||||
onClick = {
|
||||
viewModel.reportInteraction()
|
||||
viewModel.playNextUp()
|
||||
|
|
@ -545,6 +550,7 @@ fun PlaybackPage(
|
|||
subtitleStreams = mediaInfo?.subtitleStreams.orEmpty(),
|
||||
playbackSpeed = playbackSpeed,
|
||||
contentScale = contentScale,
|
||||
subtitleDelay = subtitleDelay,
|
||||
),
|
||||
onDismissRequest = {
|
||||
playbackDialog = null
|
||||
|
|
@ -555,8 +561,16 @@ fun PlaybackPage(
|
|||
onControllerInteraction = {
|
||||
controllerViewState.pulseControls(Long.MAX_VALUE)
|
||||
},
|
||||
onClickPlaybackDialogType = { playbackDialog = it },
|
||||
onClickPlaybackDialogType = {
|
||||
if (it == PlaybackDialogType.SUBTITLE_DELAY) {
|
||||
// Hide controls so subtitles are fully visible
|
||||
controllerViewState.hideControls()
|
||||
}
|
||||
playbackDialog = it
|
||||
},
|
||||
onPlaybackActionClick = onPlaybackActionClick,
|
||||
onChangeSubtitleDelay = { viewModel.updateSubtitleDelay(it) },
|
||||
enableSubtitleDelay = player is MpvPlayer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import androidx.media3.exoplayer.DecoderCounters
|
|||
import androidx.media3.exoplayer.DecoderReuseEvaluation
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.analytics.AnalyticsListener
|
||||
import coil3.imageLoader
|
||||
import coil3.request.ImageRequest
|
||||
import com.github.damontecres.wholphin.data.ItemPlaybackDao
|
||||
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
|
|
@ -29,8 +31,6 @@ import com.github.damontecres.wholphin.data.model.Chapter
|
|||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||
import com.github.damontecres.wholphin.data.model.Playlist
|
||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||
import com.github.damontecres.wholphin.data.model.chooseSource
|
||||
import com.github.damontecres.wholphin.data.model.chooseStream
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||
import com.github.damontecres.wholphin.preferences.ShowNextUpWhen
|
||||
|
|
@ -43,6 +43,7 @@ import com.github.damontecres.wholphin.services.PlayerFactory
|
|||
import com.github.damontecres.wholphin.services.PlaylistCreationResult
|
||||
import com.github.damontecres.wholphin.services.PlaylistCreator
|
||||
import com.github.damontecres.wholphin.services.RefreshRateService
|
||||
import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.onMain
|
||||
|
|
@ -93,6 +94,7 @@ import org.jellyfin.sdk.model.api.PlayMethod
|
|||
import org.jellyfin.sdk.model.api.PlaybackInfoDto
|
||||
import org.jellyfin.sdk.model.api.PlaystateCommand
|
||||
import org.jellyfin.sdk.model.api.PlaystateMessage
|
||||
import org.jellyfin.sdk.model.api.TrickplayInfo
|
||||
import org.jellyfin.sdk.model.extensions.inWholeTicks
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
|
|
@ -100,6 +102,7 @@ import timber.log.Timber
|
|||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
|
|
@ -123,6 +126,7 @@ class PlaybackViewModel
|
|||
private val deviceInfo: DeviceInfo,
|
||||
private val deviceProfileService: DeviceProfileService,
|
||||
private val refreshRateService: RefreshRateService,
|
||||
val streamChoiceService: StreamChoiceService,
|
||||
) : ViewModel(),
|
||||
Player.Listener,
|
||||
AnalyticsListener {
|
||||
|
|
@ -225,9 +229,13 @@ class PlaybackViewModel
|
|||
"Error preparing for playback for $itemId",
|
||||
),
|
||||
) {
|
||||
val destItem = (destination as? Destination.Playback)?.item?.data
|
||||
val queriedItem =
|
||||
(destination as? Destination.Playback)?.item?.data
|
||||
?: api.userLibraryApi.getItem(itemId).content
|
||||
if (destItem?.mediaSources != null) {
|
||||
destItem
|
||||
} else {
|
||||
api.userLibraryApi.getItem(itemId).content
|
||||
}
|
||||
val base =
|
||||
if (queriedItem.type.playable) {
|
||||
queriedItem
|
||||
|
|
@ -338,7 +346,8 @@ class PlaybackViewModel
|
|||
}
|
||||
}
|
||||
}
|
||||
val mediaSource = chooseSource(base, playbackConfig)
|
||||
val mediaSource = streamChoiceService.chooseSource(base, playbackConfig)
|
||||
val plc = streamChoiceService.getPlaybackLanguageChoice(base)
|
||||
|
||||
if (mediaSource == null) {
|
||||
showToast(
|
||||
|
|
@ -361,13 +370,27 @@ class PlaybackViewModel
|
|||
?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels })
|
||||
.orEmpty()
|
||||
|
||||
val audioIndex =
|
||||
chooseStream(base, playbackConfig, MediaStreamType.AUDIO, preferences)
|
||||
?.index
|
||||
val audioStream =
|
||||
streamChoiceService
|
||||
.chooseAudioStream(
|
||||
source = mediaSource,
|
||||
seriesId = base.seriesId,
|
||||
itemPlayback = playbackConfig,
|
||||
plc = plc,
|
||||
prefs = preferences,
|
||||
)
|
||||
val audioIndex = audioStream?.index
|
||||
|
||||
val subtitleIndex =
|
||||
chooseStream(base, playbackConfig, MediaStreamType.SUBTITLE, preferences)
|
||||
?.index
|
||||
streamChoiceService
|
||||
.chooseSubtitleStream(
|
||||
source = mediaSource,
|
||||
audioStream = audioStream,
|
||||
seriesId = base.seriesId,
|
||||
itemPlayback = playbackConfig,
|
||||
plc = plc,
|
||||
prefs = preferences,
|
||||
)?.index
|
||||
|
||||
Timber.d("Selected mediaSource=${mediaSource.id}, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex")
|
||||
|
||||
|
|
@ -385,6 +408,17 @@ class PlaybackViewModel
|
|||
?.get(mediaSource.id)
|
||||
?.values
|
||||
?.firstOrNull()
|
||||
trickPlayInfo?.let { trickplayInfo ->
|
||||
mediaSource.runTimeTicks?.ticks?.let { duration ->
|
||||
viewModelScope.launchIO {
|
||||
prefetchTrickplay(
|
||||
duration,
|
||||
trickplayInfo,
|
||||
mediaSource.id?.toUUIDOrNull(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val chapters = Chapter.fromDto(base, api)
|
||||
withContext(Dispatchers.Main) {
|
||||
|
|
@ -481,7 +515,7 @@ class PlaybackViewModel
|
|||
|
||||
this@PlaybackViewModel.currentItemPlayback.value = itemPlayback
|
||||
}
|
||||
|
||||
loadSubtitleDelay()
|
||||
return@withContext
|
||||
}
|
||||
} else {
|
||||
|
|
@ -563,7 +597,7 @@ class PlaybackViewModel
|
|||
|
||||
else -> throw Exception("No supported playback method")
|
||||
}
|
||||
Timber.v("Playback decision: $transcodeType")
|
||||
Timber.i("Playback decision for $itemId: $transcodeType")
|
||||
|
||||
val externalSubtitleCount = source.externalSubtitlesCount
|
||||
|
||||
|
|
@ -605,21 +639,6 @@ class PlaybackViewModel
|
|||
liveStreamId = source.liveStreamId,
|
||||
mediaSourceInfo = source,
|
||||
)
|
||||
val itemPlayback =
|
||||
currentItemPlayback.copy(
|
||||
sourceId = source.id?.toUUIDOrNull(),
|
||||
audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED,
|
||||
subtitleIndex = subtitleIndex ?: TrackIndex.DISABLED,
|
||||
)
|
||||
if (userInitiated) {
|
||||
viewModelScope.launchIO {
|
||||
Timber.v("Saving user initiated item playback: %s", itemPlayback)
|
||||
val updated = itemPlaybackRepository.saveItemPlayback(itemPlayback)
|
||||
withContext(Dispatchers.Main) {
|
||||
this@PlaybackViewModel.currentItemPlayback.value = updated
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) {
|
||||
source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }?.let {
|
||||
|
|
@ -638,14 +657,13 @@ class PlaybackViewModel
|
|||
api = api,
|
||||
player = player,
|
||||
playback = playback,
|
||||
itemPlayback = itemPlayback,
|
||||
itemPlayback = currentItemPlayback,
|
||||
)
|
||||
player.addListener(activityListener)
|
||||
this@PlaybackViewModel.activityListener = activityListener
|
||||
|
||||
loading.value = LoadingState.Success
|
||||
this@PlaybackViewModel.currentPlayback.update { playback }
|
||||
this@PlaybackViewModel.currentItemPlayback.value = itemPlayback
|
||||
player.setMediaItem(
|
||||
mediaItem,
|
||||
positionMs,
|
||||
|
|
@ -668,6 +686,7 @@ class PlaybackViewModel
|
|||
if (result.bothSelected) {
|
||||
player.removeListener(this)
|
||||
}
|
||||
viewModelScope.launchIO { loadSubtitleDelay() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -679,41 +698,82 @@ class PlaybackViewModel
|
|||
|
||||
fun changeAudioStream(index: Int) {
|
||||
viewModelScope.launchIO {
|
||||
Timber.d("Changing audio track to %s", index)
|
||||
val itemPlayback =
|
||||
itemPlaybackRepository.saveTrackSelection(
|
||||
item = item,
|
||||
itemPlayback = currentItemPlayback.value!!,
|
||||
trackIndex = index,
|
||||
type = MediaStreamType.AUDIO,
|
||||
)
|
||||
this@PlaybackViewModel.currentItemPlayback.setValueOnMain(itemPlayback)
|
||||
changeStreams(
|
||||
item,
|
||||
currentItemPlayback.value!!,
|
||||
itemPlayback,
|
||||
index,
|
||||
currentItemPlayback.value?.subtitleIndex,
|
||||
itemPlayback.subtitleIndex,
|
||||
onMain { player.currentPosition },
|
||||
true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun changeSubtitleStream(index: Int?): Job =
|
||||
fun changeSubtitleStream(index: Int): Job =
|
||||
viewModelScope.launchIO {
|
||||
Timber.d("Changing subtitle track to %s", index)
|
||||
val itemPlayback =
|
||||
itemPlaybackRepository.saveTrackSelection(
|
||||
item = item,
|
||||
itemPlayback = currentItemPlayback.value!!,
|
||||
trackIndex = index,
|
||||
type = MediaStreamType.SUBTITLE,
|
||||
)
|
||||
this@PlaybackViewModel.currentItemPlayback.setValueOnMain(itemPlayback)
|
||||
changeStreams(
|
||||
item,
|
||||
currentItemPlayback.value!!,
|
||||
currentItemPlayback.value?.audioIndex,
|
||||
itemPlayback,
|
||||
itemPlayback.audioIndex,
|
||||
index,
|
||||
onMain { player.currentPosition },
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
fun getTrickplayUrl(index: Int): String? {
|
||||
val itemId = item.id
|
||||
val mediaSourceId = currentItemPlayback.value?.sourceId
|
||||
val trickPlayInfo = currentMediaInfo.value?.trickPlayInfo ?: return null
|
||||
return api.trickplayApi.getTrickplayTileImageUrl(
|
||||
itemId,
|
||||
trickPlayInfo.width,
|
||||
index,
|
||||
mediaSourceId,
|
||||
)
|
||||
private suspend fun prefetchTrickplay(
|
||||
duration: Duration,
|
||||
trickplayInfo: TrickplayInfo,
|
||||
mediaSourceId: UUID?,
|
||||
) {
|
||||
val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight
|
||||
val totalCount =
|
||||
(duration.inWholeMilliseconds / trickplayInfo.interval).toInt() / tilesPerImage + 1
|
||||
(0..<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) {
|
||||
if (playbackState == Player.STATE_ENDED) {
|
||||
viewModelScope.launchIO {
|
||||
|
|
@ -722,7 +782,7 @@ class PlaybackViewModel
|
|||
withContext(Dispatchers.Main) {
|
||||
nextUp.value = nextItem
|
||||
if (nextItem == null) {
|
||||
controllerViewState.showControls()
|
||||
navigationManager.goBack()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1091,4 +1151,47 @@ class PlaybackViewModel
|
|||
Timber.d("decoder: onAudioDisabled")
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,29 @@
|
|||
package com.github.damontecres.wholphin.ui.playback
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.drawscope.scale
|
||||
import androidx.compose.ui.graphics.drawscope.translate
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.request.CachePolicy
|
||||
import coil3.compose.rememberAsyncImagePainter
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.transformations
|
||||
import com.github.damontecres.wholphin.ui.CoilTrickplayTransformation
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import org.jellyfin.sdk.model.api.TrickplayInfo
|
||||
|
||||
|
|
@ -70,54 +75,70 @@ fun Modifier.offsetByPercent(xPercentage: Float) =
|
|||
@Composable
|
||||
fun SeekPreviewImage(
|
||||
previewImageUrl: String,
|
||||
duration: Long,
|
||||
seekProgressMs: Long,
|
||||
videoWidth: Int?,
|
||||
videoHeight: Int?,
|
||||
trickPlayInfo: TrickplayInfo,
|
||||
modifier: Modifier = Modifier,
|
||||
placeHolder: Painter? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
if (previewImageUrl.isNotNullOrBlank() &&
|
||||
videoWidth != null &&
|
||||
videoHeight != null
|
||||
) {
|
||||
if (previewImageUrl.isNotNullOrBlank()) {
|
||||
val height = 160.dp
|
||||
val width = height * (videoWidth.toFloat() / videoHeight)
|
||||
val heightPx = with(LocalDensity.current) { height.toPx().toInt() }
|
||||
val widthPx = with(LocalDensity.current) { width.toPx().toInt() }
|
||||
val width = height * (trickPlayInfo.width.toFloat() / trickPlayInfo.height)
|
||||
val scale = with(LocalDensity.current) { width.toPx() / trickPlayInfo.width }
|
||||
|
||||
val index = (seekProgressMs.toDouble() / trickPlayInfo.interval).toInt() // Which tile
|
||||
val numberOfTitlesPerImage = trickPlayInfo.tileHeight * trickPlayInfo.tileWidth
|
||||
val imageIndex = index % numberOfTitlesPerImage
|
||||
|
||||
AsyncImage(
|
||||
modifier =
|
||||
modifier
|
||||
.width(width)
|
||||
.height(height)
|
||||
.background(Color.Black)
|
||||
.border(1.5.dp, color = MaterialTheme.colorScheme.border),
|
||||
model =
|
||||
val model =
|
||||
remember(previewImageUrl) {
|
||||
ImageRequest
|
||||
.Builder(context)
|
||||
.data(previewImageUrl)
|
||||
.memoryCachePolicy(CachePolicy.ENABLED)
|
||||
.transformations(
|
||||
CoilTrickplayTransformation(
|
||||
widthPx,
|
||||
heightPx,
|
||||
trickPlayInfo.tileHeight,
|
||||
trickPlayInfo.tileWidth,
|
||||
imageIndex,
|
||||
index,
|
||||
),
|
||||
).build(),
|
||||
contentScale = ContentScale.None,
|
||||
contentDescription = null,
|
||||
placeholder = placeHolder,
|
||||
)
|
||||
.size(coil3.size.Size.ORIGINAL)
|
||||
.build()
|
||||
}
|
||||
val painter =
|
||||
rememberAsyncImagePainter(
|
||||
model = model,
|
||||
contentScale = ContentScale.None,
|
||||
)
|
||||
val index =
|
||||
(seekProgressMs.toDouble() / trickPlayInfo.interval).toInt() // Index of tile across images
|
||||
val numberOfTilesPerImage = trickPlayInfo.tileHeight * trickPlayInfo.tileWidth
|
||||
val tileIndex =
|
||||
index % numberOfTilesPerImage // Index of tile within the current image
|
||||
val x = (tileIndex % trickPlayInfo.tileWidth) // x position within tile grid
|
||||
val y = (tileIndex / trickPlayInfo.tileHeight) // y position
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.border(1.5.dp, color = MaterialTheme.colorScheme.border)
|
||||
.background(Color.Black)
|
||||
.height(height)
|
||||
.width(width),
|
||||
) {
|
||||
Canvas(
|
||||
modifier =
|
||||
Modifier
|
||||
.height(height)
|
||||
.width(width)
|
||||
.clip(RectangleShape),
|
||||
) {
|
||||
with(painter) {
|
||||
// Scale and translate to the right position in the tile grid
|
||||
scale(scale, scale, pivot = Offset.Zero) {
|
||||
translate(
|
||||
left = -x.toFloat() * trickPlayInfo.width,
|
||||
top = -y.toFloat() * trickPlayInfo.height,
|
||||
) {
|
||||
draw(
|
||||
size =
|
||||
Size(
|
||||
trickPlayInfo.width * trickPlayInfo.tileWidth.toFloat(),
|
||||
trickPlayInfo.height * trickPlayInfo.tileHeight.toFloat(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ fun SkipIndicator(
|
|||
modifier = Modifier.align(Alignment.Center),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 13.sp,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
text = abs(durationMs / 1000).toString(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,6 @@ import androidx.lifecycle.viewModelScope
|
|||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||
import com.github.damontecres.wholphin.data.model.chooseSource
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.onMain
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
|
|
@ -101,7 +100,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
|
|||
api.userLibraryApi.getItem(itemId = it.itemId).content,
|
||||
api,
|
||||
)
|
||||
val mediaSource = chooseSource(item.data, it)
|
||||
val mediaSource = streamChoiceService.chooseSource(item.data, it)
|
||||
if (mediaSource == null) {
|
||||
// This shouldn't happen, but just in case
|
||||
showToast(
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import com.github.damontecres.wholphin.data.model.NavPinType
|
|||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.resetSubtitles
|
||||
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
|
|
@ -37,6 +38,7 @@ class PreferencesViewModel
|
|||
private val api: ApiClient,
|
||||
val preferenceDataStore: DataStore<AppPreferences>,
|
||||
val navigationManager: NavigationManager,
|
||||
val backdropService: BackdropService,
|
||||
private val rememberTabManager: RememberTabManager,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val navDrawerItemRepository: NavDrawerItemRepository,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -38,9 +39,14 @@ fun StringInputDialog(
|
|||
elevation: Dp = 3.dp,
|
||||
) {
|
||||
val mutableValue = rememberTextFieldState(input.value ?: "")
|
||||
var mutableText by remember { mutableStateOf(input.value ?: "") }
|
||||
// var mutableValue by remember { mutableStateOf(input.value ?: "") }
|
||||
val onDone = {
|
||||
onSave.invoke(mutableValue.text.toString())
|
||||
if (input.maxLines > 1) {
|
||||
onSave.invoke(mutableValue.text.toString())
|
||||
} else {
|
||||
onSave.invoke(mutableText)
|
||||
}
|
||||
}
|
||||
var showConfirm by remember { mutableStateOf(false) }
|
||||
Dialog(
|
||||
|
|
@ -79,17 +85,34 @@ fun StringInputDialog(
|
|||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier,
|
||||
)
|
||||
EditTextBox(
|
||||
state = mutableValue,
|
||||
keyboardOptions = input.keyboardOptions,
|
||||
onKeyboardAction = {
|
||||
onDone.invoke()
|
||||
},
|
||||
leadingIcon = null,
|
||||
isInputValid = { true },
|
||||
maxLines = input.maxLines,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (input.maxLines > 1) {
|
||||
EditTextBox(
|
||||
state = mutableValue,
|
||||
keyboardOptions = input.keyboardOptions,
|
||||
onKeyboardAction = {
|
||||
onDone.invoke()
|
||||
},
|
||||
leadingIcon = null,
|
||||
isInputValid = { true },
|
||||
maxLines = input.maxLines,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
} else {
|
||||
EditTextBox(
|
||||
value = mutableText,
|
||||
onValueChange = { mutableText = it },
|
||||
keyboardOptions = input.keyboardOptions,
|
||||
keyboardActions =
|
||||
KeyboardActions(
|
||||
onDone = {
|
||||
onDone.invoke()
|
||||
},
|
||||
),
|
||||
leadingIcon = null,
|
||||
isInputValid = { true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceAround,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,17 @@
|
|||
package com.github.damontecres.wholphin.ui.setup
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
|
|
@ -13,18 +22,26 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.tv.material3.Border
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.IconButtonDefaults
|
||||
import androidx.tv.material3.ListItem
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.ui.Cards
|
||||
import com.github.damontecres.wholphin.ui.components.CircularProgress
|
||||
import com.github.damontecres.wholphin.ui.components.DialogItem
|
||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||
|
|
@ -157,3 +174,261 @@ fun ServerList(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a consistent color for a UUID
|
||||
*/
|
||||
@Composable
|
||||
fun rememberIdColor(
|
||||
id: UUID?,
|
||||
nullColor: Color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
): Color =
|
||||
remember(id) {
|
||||
if (id == null) {
|
||||
return@remember nullColor
|
||||
}
|
||||
// Generate a color based on the server ID hash, fallback to URL hash
|
||||
val hash = id.hashCode()
|
||||
val hue = (hash % 360).toFloat()
|
||||
val saturation = 0.6f + ((hash / 360) % 40).toFloat() / 100f // 0.6-1.0
|
||||
val brightness = 0.4f + ((hash / 14400) % 30).toFloat() / 100f // 0.4-0.7 (darker colors)
|
||||
|
||||
// Convert HSV to RGB
|
||||
val c = brightness * saturation
|
||||
val x = c * (1 - kotlin.math.abs((hue / 60f) % 2f - 1))
|
||||
val m = brightness - c
|
||||
|
||||
val (r, g, b) =
|
||||
when {
|
||||
hue < 60 -> Triple(c, x, 0f)
|
||||
hue < 120 -> Triple(x, c, 0f)
|
||||
hue < 180 -> Triple(0f, c, x)
|
||||
hue < 240 -> Triple(0f, x, c)
|
||||
hue < 300 -> Triple(x, 0f, c)
|
||||
else -> Triple(c, 0f, x)
|
||||
}
|
||||
|
||||
Color(
|
||||
red = (r + m).coerceIn(0f, 1f),
|
||||
green = (g + m).coerceIn(0f, 1f),
|
||||
blue = (b + m).coerceIn(0f, 1f),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Server icon card component - displays a circular card with server name/letter
|
||||
*/
|
||||
@Composable
|
||||
fun ServerIconCard(
|
||||
server: JellyfinServer,
|
||||
connectionStatus: ServerConnectionStatus,
|
||||
isCurrentServer: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
allowDelete: Boolean = false,
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
// Generate unique color for this server
|
||||
val serverColor = rememberIdColor(server.id)
|
||||
|
||||
// Card dimensions - circular card
|
||||
val cardSize = Cards.serverUserCircle
|
||||
|
||||
val displayText =
|
||||
remember(server) {
|
||||
(server.name ?: server.url.replace(Regex("^https?://"), ""))
|
||||
.firstOrNull()
|
||||
?.uppercase()
|
||||
?: "?"
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
// Circular card with colored background
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
onLongClick = if (allowDelete) onLongClick else null,
|
||||
interactionSource = interactionSource,
|
||||
modifier = Modifier.size(cardSize),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
|
||||
colors =
|
||||
ClickableSurfaceDefaults.colors(
|
||||
containerColor =
|
||||
if (isCurrentServer) {
|
||||
serverColor.copy(alpha = 0.7f)
|
||||
} else {
|
||||
serverColor.copy(alpha = 0.5f)
|
||||
},
|
||||
focusedContainerColor =
|
||||
if (isCurrentServer) {
|
||||
serverColor.copy(alpha = 0.9f)
|
||||
} else {
|
||||
serverColor.copy(alpha = 0.7f)
|
||||
},
|
||||
),
|
||||
border =
|
||||
ClickableSurfaceDefaults.border(
|
||||
focusedBorder =
|
||||
Border(
|
||||
border =
|
||||
BorderStroke(
|
||||
width = 3.dp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
shape = CircleShape,
|
||||
),
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.2f),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// Show connection status indicator or server name/letter
|
||||
when (connectionStatus) {
|
||||
is ServerConnectionStatus.Success -> {
|
||||
// Show server name/letter
|
||||
|
||||
Text(
|
||||
text = displayText,
|
||||
style =
|
||||
MaterialTheme.typography.displayLarge.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
ServerConnectionStatus.Pending -> {
|
||||
CircularProgress(
|
||||
modifier = Modifier.size(cardSize * 0.4f),
|
||||
)
|
||||
}
|
||||
|
||||
is ServerConnectionStatus.Error -> {
|
||||
// Show warning icon with server letter below
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Warning,
|
||||
contentDescription = connectionStatus.message,
|
||||
tint = MaterialTheme.colorScheme.errorContainer,
|
||||
modifier = Modifier.size(cardSize * 0.3f),
|
||||
)
|
||||
Text(
|
||||
text = displayText,
|
||||
style =
|
||||
MaterialTheme.typography.titleLarge.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Server name below the card
|
||||
Text(
|
||||
text = server.name?.ifBlank { null } ?: server.url,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier =
|
||||
Modifier
|
||||
.width(cardSize)
|
||||
.padding(horizontal = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Server card component - displays a + icon in a circle
|
||||
*/
|
||||
@Composable
|
||||
fun AddServerCard(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
// Use a neutral gray color for the add server card
|
||||
val addServerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
|
||||
// Card dimensions - circular card (same as server cards)
|
||||
val cardSize = Cards.height2x3 * 0.75f // ~120dp
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
// Circular card with colored background
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
interactionSource = interactionSource,
|
||||
modifier = Modifier.size(cardSize),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
|
||||
colors =
|
||||
ClickableSurfaceDefaults.colors(
|
||||
containerColor = addServerColor.copy(alpha = 0.4f),
|
||||
focusedContainerColor = addServerColor.copy(alpha = 0.6f),
|
||||
),
|
||||
border =
|
||||
ClickableSurfaceDefaults.border(
|
||||
focusedBorder =
|
||||
Border(
|
||||
border =
|
||||
BorderStroke(
|
||||
width = 3.dp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
shape = CircleShape,
|
||||
),
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.2f),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = stringResource(R.string.add_server),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.size(cardSize * 0.4f), // Size of the + icon
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// "Add Server" text below the card
|
||||
Text(
|
||||
text = stringResource(R.string.add_server),
|
||||
style =
|
||||
MaterialTheme.typography.bodyLarge.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier =
|
||||
Modifier
|
||||
.width(cardSize)
|
||||
.padding(horizontal = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,23 @@
|
|||
package com.github.damontecres.wholphin.ui.setup
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -22,6 +29,8 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
|
|
@ -29,19 +38,21 @@ import androidx.compose.ui.text.input.KeyboardType
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.tv.material3.ListItem
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import androidx.tv.material3.surfaceColorAtElevation
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.ui.components.BasicDialog
|
||||
import com.github.damontecres.wholphin.ui.components.CircularProgress
|
||||
import com.github.damontecres.wholphin.ui.components.DialogItem
|
||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||
import com.github.damontecres.wholphin.ui.components.EditTextBox
|
||||
import com.github.damontecres.wholphin.ui.components.TextButton
|
||||
import com.github.damontecres.wholphin.ui.dimAndBlur
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import org.jellyfin.sdk.model.api.PublicSystemInfo
|
||||
|
||||
@Composable
|
||||
fun SwitchServerContent(
|
||||
|
|
@ -51,141 +62,185 @@ fun SwitchServerContent(
|
|||
val servers by viewModel.servers.observeAsState(listOf())
|
||||
val serverStatus by viewModel.serverStatus.observeAsState(mapOf())
|
||||
|
||||
val discoveredServers by viewModel.discoveredServers.observeAsState(listOf())
|
||||
|
||||
var showAddServer by remember { mutableStateOf(false) }
|
||||
var showDeleteDialog by remember { mutableStateOf<com.github.damontecres.wholphin.data.model.JellyfinServer?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.init()
|
||||
viewModel.discoverServers()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier.dimAndBlur(showAddServer),
|
||||
modifier = modifier.dimAndBlur(showAddServer || showDeleteDialog != null),
|
||||
) {
|
||||
Row(
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
// Center the content like the Select User screen
|
||||
.align(Alignment.Center)
|
||||
.padding(32.dp),
|
||||
.padding(16.dp),
|
||||
) {
|
||||
// Match SwitchUser header height (title + subtitle) to align icons vertically across screens
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.padding(16.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.select_server),
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
ServerList(
|
||||
servers = servers,
|
||||
connectionStatus = serverStatus,
|
||||
onSwitchServer = {
|
||||
viewModel.switchServer(it)
|
||||
},
|
||||
onTestServer = {
|
||||
viewModel.testServer(it)
|
||||
},
|
||||
onAddServer = {
|
||||
showAddServer = true
|
||||
},
|
||||
onRemoveServer = {
|
||||
viewModel.removeServer(it)
|
||||
},
|
||||
allowAdd = true,
|
||||
allowDelete = true,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
),
|
||||
// Invisible subtitle placeholder to mirror the server name line on the Select User screen
|
||||
Text(
|
||||
text = "Server placeholder",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = Color.Transparent,
|
||||
)
|
||||
}
|
||||
// Discover
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
|
||||
// Horizontal scrollable list of server icons - centered
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val firstServerFocus = remember { FocusRequester() }
|
||||
if (servers.isNotEmpty()) {
|
||||
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||
}
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||
contentPadding = PaddingValues(horizontal = 48.dp, vertical = 16.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.wrapContentWidth()
|
||||
.focusRestorer(firstServerFocus)
|
||||
.focusRequester(focusRequester),
|
||||
) {
|
||||
itemsIndexed(servers) { index, server ->
|
||||
val status = serverStatus[server.id] ?: ServerConnectionStatus.Pending
|
||||
ServerIconCard(
|
||||
server = server,
|
||||
connectionStatus = status,
|
||||
isCurrentServer = false, // TODO: Determine current server if needed
|
||||
onClick = {
|
||||
when (status) {
|
||||
is ServerConnectionStatus.Success -> {
|
||||
viewModel.switchServer(server)
|
||||
}
|
||||
|
||||
ServerConnectionStatus.Pending -> {
|
||||
// Do nothing while pending
|
||||
}
|
||||
|
||||
is ServerConnectionStatus.Error -> {
|
||||
viewModel.testServer(server)
|
||||
}
|
||||
}
|
||||
},
|
||||
onLongClick = {
|
||||
showDeleteDialog = server
|
||||
},
|
||||
allowDelete = true,
|
||||
modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(firstServerFocus)),
|
||||
)
|
||||
}
|
||||
// Add Server card - always rightmost
|
||||
item {
|
||||
AddServerCard(
|
||||
onClick = { showAddServer = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Non-focusable spacer to mirror the space occupied by the "Switch Servers" button
|
||||
Spacer(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.padding(16.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.discovered_servers),
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
if (discoveredServers.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.searching),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
} else {
|
||||
ServerList(
|
||||
servers = discoveredServers,
|
||||
connectionStatus =
|
||||
discoveredServers
|
||||
.map { it.id }
|
||||
.associateWith { ServerConnectionStatus.Success(PublicSystemInfo()) },
|
||||
onSwitchServer = {
|
||||
viewModel.addServer(it.url)
|
||||
.height(56.dp),
|
||||
// approximate TV button height
|
||||
)
|
||||
}
|
||||
|
||||
// Delete server dialog
|
||||
showDeleteDialog?.let { server ->
|
||||
DialogPopup(
|
||||
showDialog = true,
|
||||
title = server.name ?: server.url,
|
||||
dialogItems =
|
||||
listOf(
|
||||
DialogItem(
|
||||
stringResource(R.string.switch_servers),
|
||||
R.string.fa_arrow_left_arrow_right,
|
||||
) {
|
||||
viewModel.switchServer(server)
|
||||
showDeleteDialog = null
|
||||
},
|
||||
onTestServer = {
|
||||
viewModel.testServer(it)
|
||||
DialogItem(
|
||||
stringResource(R.string.delete),
|
||||
Icons.Default.Delete,
|
||||
Color.Red.copy(alpha = .8f),
|
||||
) {
|
||||
viewModel.removeServer(server)
|
||||
showDeleteDialog = null
|
||||
},
|
||||
onAddServer = {},
|
||||
onRemoveServer = {},
|
||||
allowAdd = false,
|
||||
allowDelete = false,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
),
|
||||
onDismissRequest = { showDeleteDialog = null },
|
||||
dismissOnClick = true,
|
||||
waitToLoad = true,
|
||||
properties = DialogProperties(),
|
||||
elevation = 5.dp,
|
||||
)
|
||||
}
|
||||
|
||||
if (showAddServer) {
|
||||
var showEnterAddress by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.clearAddServerState()
|
||||
if (!showEnterAddress) {
|
||||
viewModel.discoverServers()
|
||||
}
|
||||
}
|
||||
val state by viewModel.addServerState.observeAsState(LoadingState.Pending)
|
||||
val url = rememberTextFieldState()
|
||||
val submit = {
|
||||
viewModel.addServer(url.text.toString())
|
||||
|
||||
val discoveredServers by viewModel.discoveredServers.observeAsState(listOf())
|
||||
|
||||
// Filter out duplicates within the discovered servers list (same URL appearing multiple times)
|
||||
val filteredDiscoveredServers =
|
||||
remember(discoveredServers) {
|
||||
val seenUrls = mutableSetOf<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(
|
||||
onDismissRequest = {
|
||||
showAddServer = false
|
||||
showEnterAddress = false
|
||||
viewModel.clearAddServerState()
|
||||
},
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
elevation = 10.dp,
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
|
|
@ -194,45 +249,134 @@ fun SwitchServerContent(
|
|||
.padding(16.dp)
|
||||
.fillMaxWidth(.4f),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.enter_server_url),
|
||||
)
|
||||
EditTextBox(
|
||||
state = url,
|
||||
keyboardOptions =
|
||||
KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrectEnabled = false,
|
||||
keyboardType = KeyboardType.Uri,
|
||||
imeAction = ImeAction.Go,
|
||||
),
|
||||
onKeyboardAction = { submit.invoke() },
|
||||
modifier =
|
||||
Modifier
|
||||
.focusRequester(focusRequester)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
when (val st = state) {
|
||||
is LoadingState.Error -> {
|
||||
if (!showEnterAddress) {
|
||||
// Show discovered servers first
|
||||
Text(
|
||||
text = stringResource(R.string.discovered_servers),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
||||
if (filteredDiscoveredServers.isEmpty() && discoveredServers.isEmpty()) {
|
||||
Text(
|
||||
text =
|
||||
st.message ?: st.exception?.localizedMessage
|
||||
?: "An error occurred",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
text = stringResource(R.string.searching),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
} else if (filteredDiscoveredServers.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.no_servers_found),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 300.dp),
|
||||
) {
|
||||
items(
|
||||
filteredDiscoveredServers.size,
|
||||
key = { filteredDiscoveredServers[it].url },
|
||||
) { index ->
|
||||
val server = filteredDiscoveredServers[index]
|
||||
val focusRequester =
|
||||
if (index == 0) {
|
||||
firstDiscoveredServerFocusRequester
|
||||
} else {
|
||||
remember { FocusRequester() }
|
||||
}
|
||||
|
||||
ListItem(
|
||||
enabled = true,
|
||||
selected = false,
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = server.name?.ifBlank { null } ?: server.url,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = server.url,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
viewModel.addServer(server.url)
|
||||
},
|
||||
modifier = Modifier.focusRequester(focusRequester),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
TextButton(
|
||||
onClick = { submit.invoke() },
|
||||
enabled = url.text.isNotNullOrBlank() && state == LoadingState.Pending,
|
||||
modifier = Modifier,
|
||||
) {
|
||||
if (state == LoadingState.Loading) {
|
||||
CircularProgress(Modifier.size(32.dp))
|
||||
} else {
|
||||
Text(text = stringResource(R.string.submit))
|
||||
TextButton(
|
||||
onClick = {
|
||||
showEnterAddress = true
|
||||
},
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
) {
|
||||
Text(text = stringResource(R.string.enter_server_address))
|
||||
}
|
||||
} else {
|
||||
// Show enter server address form
|
||||
val state by viewModel.addServerState.observeAsState(LoadingState.Pending)
|
||||
var url by remember { mutableStateOf("") }
|
||||
val submit = {
|
||||
viewModel.addServer(url)
|
||||
}
|
||||
val textBoxFocusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
textBoxFocusRequester.tryRequestFocus()
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.enter_server_url),
|
||||
)
|
||||
EditTextBox(
|
||||
value = url,
|
||||
onValueChange = { url = it },
|
||||
keyboardOptions =
|
||||
KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrectEnabled = false,
|
||||
keyboardType = KeyboardType.Uri,
|
||||
imeAction = ImeAction.Go,
|
||||
),
|
||||
keyboardActions =
|
||||
KeyboardActions(
|
||||
onGo = { submit.invoke() },
|
||||
),
|
||||
modifier =
|
||||
Modifier
|
||||
.focusRequester(textBoxFocusRequester)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
when (val st = state) {
|
||||
is LoadingState.Error -> {
|
||||
Text(
|
||||
text =
|
||||
st.message ?: st.exception?.localizedMessage
|
||||
?: "An error occurred",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
TextButton(
|
||||
onClick = { submit.invoke() },
|
||||
enabled = url.isNotNullOrBlank() && state == LoadingState.Pending,
|
||||
modifier = Modifier,
|
||||
) {
|
||||
if (state == LoadingState.Loading) {
|
||||
CircularProgress(Modifier.size(32.dp))
|
||||
} else {
|
||||
Text(text = stringResource(R.string.submit))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package com.github.damontecres.wholphin.ui.setup
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
|
|
@ -11,9 +10,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -36,7 +34,6 @@ import androidx.compose.ui.window.DialogProperties
|
|||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import androidx.tv.material3.surfaceColorAtElevation
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
|
|
@ -94,27 +91,28 @@ fun SwitchUserContent(
|
|||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(.5f)
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.Center)
|
||||
.padding(16.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
),
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.select_user),
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = server.name ?: server.url,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.select_user),
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = server.name ?: server.url,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
UserList(
|
||||
users = users,
|
||||
currentUser = currentUser,
|
||||
|
|
@ -134,13 +132,7 @@ fun SwitchUserContent(
|
|||
onSwitchServer = {
|
||||
viewModel.navigationManager.navigateTo(Destination.ServerList)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -170,7 +162,7 @@ fun SwitchUserContent(
|
|||
Modifier
|
||||
.focusGroup()
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth(.66f),
|
||||
.fillMaxWidth(.4f),
|
||||
) {
|
||||
if (useQuickConnect) {
|
||||
if (quickConnect == null && userState !is LoadingState.Error) {
|
||||
|
|
@ -196,6 +188,8 @@ fun SwitchUserContent(
|
|||
text = "Use Quick Connect on your device to authenticate to ${server.name ?: server.url}",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Text(
|
||||
text = quickConnect?.code ?: "Failed to get code",
|
||||
|
|
@ -215,13 +209,15 @@ fun SwitchUserContent(
|
|||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
)
|
||||
} else {
|
||||
val username = rememberTextFieldState()
|
||||
val password = rememberTextFieldState()
|
||||
// val username = rememberTextFieldState()
|
||||
// val password = rememberTextFieldState()
|
||||
var username by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
val onSubmit = {
|
||||
viewModel.login(
|
||||
server,
|
||||
username.text.toString(),
|
||||
password.text.toString(),
|
||||
username,
|
||||
password,
|
||||
)
|
||||
}
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
|
@ -231,6 +227,8 @@ fun SwitchUserContent(
|
|||
text = "Enter username/password to login to ${server.name ?: server.url}",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
UserStateError(userState)
|
||||
Row(
|
||||
|
|
@ -242,7 +240,8 @@ fun SwitchUserContent(
|
|||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
EditTextBox(
|
||||
state = username,
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
keyboardOptions =
|
||||
KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
|
|
@ -250,9 +249,15 @@ fun SwitchUserContent(
|
|||
keyboardType = KeyboardType.Text,
|
||||
imeAction = ImeAction.Next,
|
||||
),
|
||||
onKeyboardAction = {
|
||||
passwordFocusRequester.tryRequestFocus()
|
||||
},
|
||||
keyboardActions =
|
||||
KeyboardActions(
|
||||
onNext = {
|
||||
passwordFocusRequester.tryRequestFocus()
|
||||
},
|
||||
),
|
||||
// onKeyboardAction = {
|
||||
// passwordFocusRequester.tryRequestFocus()
|
||||
// },
|
||||
isInputValid = { userState !is LoadingState.Error },
|
||||
modifier = Modifier.focusRequester(focusRequester),
|
||||
)
|
||||
|
|
@ -265,12 +270,12 @@ fun SwitchUserContent(
|
|||
text = "Password",
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
LaunchedEffect(password.text) {
|
||||
LaunchedEffect(password) {
|
||||
viewModel.clearSwitchUserState()
|
||||
}
|
||||
EditTextBox(
|
||||
isPassword = true,
|
||||
state = password,
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
keyboardOptions =
|
||||
KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
|
|
@ -278,7 +283,10 @@ fun SwitchUserContent(
|
|||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Go,
|
||||
),
|
||||
onKeyboardAction = { onSubmit.invoke() },
|
||||
keyboardActions =
|
||||
KeyboardActions(
|
||||
onGo = { onSubmit.invoke() },
|
||||
),
|
||||
isInputValid = { userState !is LoadingState.Error },
|
||||
modifier = Modifier.focusRequester(passwordFocusRequester),
|
||||
)
|
||||
|
|
@ -286,7 +294,7 @@ fun SwitchUserContent(
|
|||
TextButton(
|
||||
stringRes = R.string.login,
|
||||
onClick = { onSubmit.invoke() },
|
||||
enabled = username.text.isNotNullOrBlank() && password.text.isNotNullOrBlank(),
|
||||
enabled = username.isNotNullOrBlank(),
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import com.github.damontecres.wholphin.data.JellyfinServerDao
|
|||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
|
|
@ -25,6 +26,7 @@ import org.jellyfin.sdk.Jellyfin
|
|||
import org.jellyfin.sdk.api.client.HttpClientOptions
|
||||
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
|
||||
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
|
||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||
import org.jellyfin.sdk.api.client.extensions.quickConnectApi
|
||||
import org.jellyfin.sdk.api.client.extensions.systemApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||
|
|
@ -41,6 +43,7 @@ class SwitchUserViewModel
|
|||
val serverRepository: ServerRepository,
|
||||
val serverDao: JellyfinServerDao,
|
||||
val navigationManager: NavigationManager,
|
||||
val imageUrlService: ImageUrlService,
|
||||
@Assisted val server: JellyfinServer,
|
||||
) : ViewModel() {
|
||||
@AssistedFactory
|
||||
|
|
@ -50,7 +53,7 @@ class SwitchUserViewModel
|
|||
|
||||
val serverQuickConnect = MutableLiveData<Boolean>(false)
|
||||
|
||||
val users = MutableLiveData<List<JellyfinUser>>(listOf())
|
||||
val users = MutableLiveData<List<JellyfinUserAndImage>>(listOf())
|
||||
val quickConnectState = MutableLiveData<QuickConnectResult?>(null)
|
||||
|
||||
private var quickConnectJob: Job? = null
|
||||
|
|
@ -78,8 +81,7 @@ class SwitchUserViewModel
|
|||
quickConnectJob?.cancel()
|
||||
viewModelScope.launchIO {
|
||||
users.setValueOnMain(listOf())
|
||||
val serverUsers =
|
||||
serverDao.getServer(server.id)?.users?.sortedBy { it.name } ?: listOf()
|
||||
val serverUsers = getUsers()
|
||||
withContext(Dispatchers.Main) {
|
||||
users.setValueOnMain(serverUsers)
|
||||
}
|
||||
|
|
@ -215,14 +217,23 @@ class SwitchUserViewModel
|
|||
fun removeUser(user: JellyfinUser) {
|
||||
viewModelScope.launchIO {
|
||||
serverRepository.removeUser(user)
|
||||
val serverUsers =
|
||||
serverDao.getServer(user.serverId)?.users?.sortedBy { it.name } ?: listOf()
|
||||
val serverUsers = getUsers()
|
||||
withContext(Dispatchers.Main) {
|
||||
users.value = serverUsers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getUsers(): List<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(
|
||||
msg: String? = null,
|
||||
ex: Exception? = null,
|
||||
|
|
@ -231,3 +242,8 @@ class SwitchUserViewModel
|
|||
switchUserState.value = LoadingState.Error(msg, ex)
|
||||
}
|
||||
}
|
||||
|
||||
data class JellyfinUserAndImage(
|
||||
val user: JellyfinUser,
|
||||
val imageUrl: String?,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,37 +1,68 @@
|
|||
package com.github.damontecres.wholphin.ui.setup
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.tv.material3.Border
|
||||
import androidx.tv.material3.Button
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.ListItem
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import coil3.compose.AsyncImage
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.ui.Cards
|
||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||
import com.github.damontecres.wholphin.ui.components.DialogItem
|
||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
|
||||
/**
|
||||
* Display a list of users plus option to add a new one or switch servers
|
||||
* Redesigned to match streaming service style with horizontal scrollable user icons
|
||||
*/
|
||||
@Composable
|
||||
fun UserList(
|
||||
users: List<JellyfinUser>,
|
||||
users: List<JellyfinUserAndImage>,
|
||||
currentUser: JellyfinUser?,
|
||||
onSwitchUser: (JellyfinUser) -> Unit,
|
||||
onAddUser: () -> Unit,
|
||||
|
|
@ -41,59 +72,76 @@ fun UserList(
|
|||
) {
|
||||
var showDeleteDialog by remember { mutableStateOf<JellyfinUser?>(null) }
|
||||
|
||||
LazyColumn(modifier = modifier) {
|
||||
items(users) { user ->
|
||||
ListItem(
|
||||
enabled = true,
|
||||
selected = user == currentUser,
|
||||
headlineContent = { Text(text = user.name ?: user.id.toString()) },
|
||||
leadingContent = {
|
||||
if (user.id == currentUser?.id) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = "current user",
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = { onSwitchUser.invoke(user) },
|
||||
onLongClick = {
|
||||
showDeleteDialog = user
|
||||
},
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
item {
|
||||
HorizontalDivider()
|
||||
ListItem(
|
||||
enabled = true,
|
||||
selected = false,
|
||||
headlineContent = { Text(text = stringResource(R.string.add_user)) },
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
tint = Color.Green.copy(alpha = .8f),
|
||||
contentDescription = null,
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
// Horizontal scrollable list of user icons - centered
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val firstFocusRequester = remember { FocusRequester() }
|
||||
if (users.isNotEmpty()) {
|
||||
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||
}
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(24.dp), // Spacing to accommodate 20% scale
|
||||
contentPadding = PaddingValues(horizontal = 48.dp, vertical = 16.dp), // Increased padding to accommodate 20% scale
|
||||
modifier =
|
||||
Modifier
|
||||
.wrapContentWidth()
|
||||
.focusRestorer(firstFocusRequester)
|
||||
.focusRequester(focusRequester),
|
||||
) {
|
||||
itemsIndexed(users) { index, user ->
|
||||
UserIconCard(
|
||||
user = user,
|
||||
isCurrentUser = user.user.id == currentUser?.id,
|
||||
onClick = { onSwitchUser.invoke(user.user) },
|
||||
onLongClick = { showDeleteDialog = user.user },
|
||||
modifier = if (index == 0) Modifier.focusRequester(firstFocusRequester) else Modifier,
|
||||
)
|
||||
},
|
||||
onClick = { onAddUser.invoke() },
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
// Add User card - always rightmost
|
||||
item {
|
||||
AddUserCard(
|
||||
onClick = { onAddUser.invoke() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
HorizontalDivider()
|
||||
ListItem(
|
||||
enabled = true,
|
||||
selected = false,
|
||||
headlineContent = { Text(text = stringResource(R.string.switch_servers)) },
|
||||
leadingContent = {
|
||||
|
||||
// Switch servers button below user list - centered
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = { onSwitchServer.invoke() },
|
||||
modifier = Modifier.width(200.dp), // Fixed width for consistency
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.fa_arrow_left_arrow_right),
|
||||
fontFamily = FontAwesome,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
},
|
||||
onClick = { onSwitchServer.invoke() },
|
||||
modifier = Modifier,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.switch_servers),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
showDeleteDialog?.let { user ->
|
||||
|
|
@ -124,3 +172,193 @@ fun UserList(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserIconCard(
|
||||
user: JellyfinUserAndImage,
|
||||
isCurrentUser: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
// Generate unique color for this user
|
||||
val userColor = rememberIdColor(user.user.id)
|
||||
|
||||
// Track image loading errors
|
||||
var imageError by remember { mutableStateOf(false) }
|
||||
|
||||
// Card dimensions - circular card
|
||||
val cardSize = Cards.serverUserCircle
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
// Circular card with colored background
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
interactionSource = interactionSource,
|
||||
modifier = Modifier.size(cardSize),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
|
||||
colors =
|
||||
ClickableSurfaceDefaults.colors(
|
||||
containerColor =
|
||||
if (isCurrentUser) {
|
||||
userColor.copy(alpha = 0.7f)
|
||||
} else {
|
||||
userColor.copy(alpha = 0.5f)
|
||||
},
|
||||
focusedContainerColor =
|
||||
if (isCurrentUser) {
|
||||
userColor.copy(alpha = 0.9f)
|
||||
} else {
|
||||
userColor.copy(alpha = 0.7f)
|
||||
},
|
||||
),
|
||||
border =
|
||||
ClickableSurfaceDefaults.border(
|
||||
focusedBorder =
|
||||
Border(
|
||||
border =
|
||||
BorderStroke(
|
||||
width = 3.dp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
shape = CircleShape,
|
||||
),
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.2f),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (user.imageUrl.isNotNullOrBlank() && !imageError) {
|
||||
AsyncImage(
|
||||
model = user.imageUrl,
|
||||
contentDescription = user.user.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
onError = { imageError = true },
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clip(CircleShape),
|
||||
)
|
||||
} else {
|
||||
// Show big bold first letter of username
|
||||
val firstLetter =
|
||||
remember(user) {
|
||||
user.user.let {
|
||||
(it.name ?: it.id.toString()).firstOrNull()?.uppercase()
|
||||
} ?: "?"
|
||||
}
|
||||
Text(
|
||||
text = firstLetter,
|
||||
style =
|
||||
MaterialTheme.typography.displayLarge.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Username below the card
|
||||
Text(
|
||||
text = user.user.name ?: user.user.id.toString(),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier =
|
||||
Modifier
|
||||
.width(cardSize)
|
||||
.padding(horizontal = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add User card component - displays a + icon in a circle
|
||||
*/
|
||||
@Composable
|
||||
private fun AddUserCard(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
// Use a neutral gray color for the add user card
|
||||
val addUserColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
|
||||
// Card dimensions - circular card (same as user cards)
|
||||
val cardSize = Cards.height2x3 * 0.75f // ~120dp
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp), // Increased to accommodate 20% scale
|
||||
) {
|
||||
// Circular card with colored background
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
interactionSource = interactionSource,
|
||||
modifier = Modifier.size(cardSize),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
|
||||
colors =
|
||||
ClickableSurfaceDefaults.colors(
|
||||
containerColor = addUserColor.copy(alpha = 0.4f),
|
||||
focusedContainerColor = addUserColor.copy(alpha = 0.6f),
|
||||
),
|
||||
border =
|
||||
ClickableSurfaceDefaults.border(
|
||||
focusedBorder =
|
||||
Border(
|
||||
border =
|
||||
BorderStroke(
|
||||
width = 3.dp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
shape = CircleShape,
|
||||
),
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.2f),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = stringResource(R.string.add_user),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.size(cardSize * 0.4f), // Size of the + icon
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// "Add User" text below the card
|
||||
Text(
|
||||
text = stringResource(R.string.add_user),
|
||||
style =
|
||||
MaterialTheme.typography.bodyLarge.copy(
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier =
|
||||
Modifier
|
||||
.width(cardSize)
|
||||
.padding(horizontal = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue