mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-09 08:01:20 +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/
|
ffmpeg_decoder/
|
||||||
app/release/
|
app/release/
|
||||||
app/debug/
|
app/debug/
|
||||||
<<<<<<< HEAD
|
.vscode
|
||||||
|
.kotlin
|
||||||
|
docs/
|
||||||
|
|
||||||
# mpv
|
# mpv
|
||||||
app/src/main/obj/
|
app/src/main/obj/
|
||||||
|
|
|
||||||
|
|
@ -18,3 +18,8 @@ repos:
|
||||||
entry: val DEBUG = true
|
entry: val DEBUG = true
|
||||||
language: pygrep
|
language: pygrep
|
||||||
files: '.+\.kt'
|
files: '.+\.kt'
|
||||||
|
- id: check-debug-logging-enabled
|
||||||
|
name: Check debug enabled
|
||||||
|
entry: debugLogging = true
|
||||||
|
language: pygrep
|
||||||
|
files: '.+\.kt'
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,17 @@ We follow GitHub's fork & pull request model for contributions.
|
||||||
|
|
||||||
After forking and cloning your fork, you can import the project into Android Studio.
|
After forking and cloning your fork, you can import the project into Android Studio.
|
||||||
|
|
||||||
You need a compatible Android Studio version for the configured AGP. This is generally `Narwhal 3 Feature Drop | 2025.1.3` or newer. See https://developer.android.com/build/releases/gradle-plugin and [`libs.versions.toml](./gradle/libs.versions.toml).
|
### Development environment
|
||||||
|
|
||||||
### Code organization
|
It is recommended to use a recent version of [Android Studio](https://developer.android.com/studio). Make sure the [version is compatible](https://developer.android.com/build/releases/gradle-plugin#android_gradle_plugin_and_android_studio_compatibility) with Wholphin's AGP version.
|
||||||
|
|
||||||
|
Code formatting should follow [ktlint's](https://github.com/pinterest/ktlint) rules. Find the `ktlint` version in [`.pre-commit-config.yaml`](./.pre-commit-config.yaml). Optionally, install the [ktlint plugin](https://plugins.jetbrains.com/plugin/15057-ktlint) in Android Studio to run automatically. Configure the version in `Settings->Tools->KtLint->Ruleset Version`.
|
||||||
|
|
||||||
|
Also, it's recommend to add an extra ruleset jar for Compose-specific KtLint: https://mrmans0n.github.io/compose-rules/ktlint/#using-with-ktlint-cli-or-the-ktlint-intellij-plugin
|
||||||
|
|
||||||
|
Also setup [pre-commit](https://github.com/pre-commit/pre-commit) which will run `ktlint` as well on each commit, plus check for other common issues.
|
||||||
|
|
||||||
|
## Code organization
|
||||||
|
|
||||||
Code is split into several packages:
|
Code is split into several packages:
|
||||||
- `data` - app-specific data models and services
|
- `data` - app-specific data models and services
|
||||||
|
|
|
||||||
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).
|
This is not a fork of the [official client](https://github.com/jellyfin/jellyfin-androidtv). Wholphin's user interface and controls have been written completely from scratch. Wholphin `v0.3.0+` supports playing media using either ExoPlayer/Media3 or MPV (experimental).
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/damontecres/Wholphin/issues/303"><b>Help get Wholphin listed on the Play Store</b></a>
|
|
||||||
<br/>
|
|
||||||
<br/>
|
|
||||||
<a href="https://github.com/damontecres/Wholphin/releases">
|
<a href="https://github.com/damontecres/Wholphin/releases">
|
||||||
<img alt="Current Release" src="https://img.shields.io/github/release/damontecres/wholphin.svg"/>
|
<img alt="Current Release" src="https://img.shields.io/github/release/damontecres/wholphin.svg"/>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://translate.codeberg.org/engage/wholphin/">
|
<a href="https://translate.codeberg.org/engage/wholphin/">
|
||||||
<img src="https://translate.codeberg.org/widget/wholphin/wholphin/svg-badge.svg" alt="Translation status" />
|
<img src="https://translate.codeberg.org/widget/wholphin/wholphin/svg-badge.svg" alt="Translation status" />
|
||||||
</a>
|
</a>
|
||||||
|
<br/>
|
||||||
|
<a href="https://play.google.com/store/apps/details?id=com.github.damontecres.wholphin">
|
||||||
|
<img width="180" alt="Get Wholphin on Google Play" src="https://github.com/user-attachments/assets/2550a4cb-ce46-47a1-ae24-f33a169234b7"/>
|
||||||
|
</a>
|
||||||
|
<a href="https://www.amazon.com/gp/product/B0G8RQQR9T/ref=mas_pm_wholphin">
|
||||||
|
<img width="180" alt="Get Wholphin on Amazon AppStore" src="https://github.com/user-attachments/assets/1f3a3b26-4b4f-44b1-9741-f4c895c8a53b"/>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<img width="1280" height="720" alt="0_3_5_home" src="https://github.com/user-attachments/assets/a485c015-ec21-442d-a757-1f18381bf799" />
|
<img width="1280" height="720" alt="0_3_5_home" src="https://github.com/user-attachments/assets/a485c015-ec21-442d-a757-1f18381bf799" />
|
||||||
|
|
@ -24,12 +30,12 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin
|
||||||
|
|
||||||
### User interface
|
### User interface
|
||||||
|
|
||||||
- A navigation drawer for quick access to libraries, search, and settings from almost anywhere in the app
|
- A navigation drawer for quick access to libraries, favorites, search, and settings from almost anywhere in the app
|
||||||
- Option to combine Continue Watching & Next Up rows
|
- Option to combine Continue Watching & Next Up rows
|
||||||
- Show Movie/TV Show titles when browsing libraries
|
- Show Movie/TV Show titles when browsing libraries
|
||||||
- Play theme music, if available
|
- Play theme music, if available
|
||||||
|
- Search & download subtitles (requires compatible server plugin such as [OpenSubtitles](https://github.com/jellyfin/jellyfin-plugin-opensubtitles))
|
||||||
- Customize layout grids for libraries
|
- Customize layout grids for libraries
|
||||||
- Access all your favorites quickly from the nav drawer
|
|
||||||
- Multiple app color themes
|
- Multiple app color themes
|
||||||
|
|
||||||
### Playback
|
### Playback
|
||||||
|
|
@ -43,6 +49,8 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin
|
||||||
- Optionally skip back a few seconds when resuming playback
|
- Optionally skip back a few seconds when resuming playback
|
||||||
- Live TV & DVR support
|
- Live TV & DVR support
|
||||||
- Auto play next episodes with pass out protection
|
- Auto play next episodes with pass out protection
|
||||||
|
- Option for automatic refresh rate switching on supported displays
|
||||||
|
- Trickplay support
|
||||||
- Other (subjective) enhancements:
|
- Other (subjective) enhancements:
|
||||||
- Subtly show playback position along the bottom of the screen while seeking w/ D-Pad
|
- Subtly show playback position along the bottom of the screen while seeking w/ D-Pad
|
||||||
- Force Continue Watching & Next Up TV episodes to use their Series posters
|
- Force Continue Watching & Next Up TV episodes to use their Series posters
|
||||||
|
|
|
||||||
|
|
@ -262,6 +262,7 @@ dependencies {
|
||||||
implementation(libs.androidx.hilt.navigation.compose)
|
implementation(libs.androidx.hilt.navigation.compose)
|
||||||
implementation(libs.androidx.preference.ktx)
|
implementation(libs.androidx.preference.ktx)
|
||||||
implementation(libs.androidx.room.testing)
|
implementation(libs.androidx.room.testing)
|
||||||
|
implementation(libs.androidx.palette.ktx)
|
||||||
ksp(libs.androidx.room.compiler)
|
ksp(libs.androidx.room.compiler)
|
||||||
ksp(libs.hilt.android.compiler)
|
ksp(libs.hilt.android.compiler)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.ApplicationContent
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
|
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
|
||||||
import com.github.damontecres.wholphin.util.DebugLogTree
|
import com.github.damontecres.wholphin.util.DebugLogTree
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
@ -222,6 +223,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ProvideLocalClock {
|
||||||
ApplicationContent(
|
ApplicationContent(
|
||||||
user = current?.user,
|
user = current?.user,
|
||||||
server = current?.server,
|
server = current?.server,
|
||||||
|
|
@ -237,6 +239,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,12 @@ import androidx.room.migration.Migration
|
||||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||||
|
import com.github.damontecres.wholphin.data.model.ItemTrackModification
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
|
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
|
||||||
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
|
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
|
||||||
import com.github.damontecres.wholphin.ui.components.ViewOptions
|
import com.github.damontecres.wholphin.ui.components.ViewOptions
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
|
|
@ -22,8 +24,16 @@ import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [JellyfinServer::class, JellyfinUser::class, ItemPlayback::class, NavDrawerPinnedItem::class, LibraryDisplayInfo::class],
|
entities = [
|
||||||
version = 10,
|
JellyfinServer::class,
|
||||||
|
JellyfinUser::class,
|
||||||
|
ItemPlayback::class,
|
||||||
|
NavDrawerPinnedItem::class,
|
||||||
|
LibraryDisplayInfo::class,
|
||||||
|
PlaybackLanguageChoice::class,
|
||||||
|
ItemTrackModification::class,
|
||||||
|
],
|
||||||
|
version = 12,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
autoMigrations = [
|
autoMigrations = [
|
||||||
AutoMigration(3, 4),
|
AutoMigration(3, 4),
|
||||||
|
|
@ -33,6 +43,8 @@ import java.util.UUID
|
||||||
AutoMigration(7, 8),
|
AutoMigration(7, 8),
|
||||||
AutoMigration(8, 9),
|
AutoMigration(8, 9),
|
||||||
AutoMigration(9, 10),
|
AutoMigration(9, 10),
|
||||||
|
AutoMigration(10, 11),
|
||||||
|
AutoMigration(11, 12),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@TypeConverters(Converters::class)
|
@TypeConverters(Converters::class)
|
||||||
|
|
@ -44,6 +56,8 @@ abstract class AppDatabase : RoomDatabase() {
|
||||||
abstract fun serverPreferencesDao(): ServerPreferencesDao
|
abstract fun serverPreferencesDao(): ServerPreferencesDao
|
||||||
|
|
||||||
abstract fun libraryDisplayInfoDao(): LibraryDisplayInfoDao
|
abstract fun libraryDisplayInfoDao(): LibraryDisplayInfoDao
|
||||||
|
|
||||||
|
abstract fun playbackLanguageChoiceDao(): PlaybackLanguageChoiceDao
|
||||||
}
|
}
|
||||||
|
|
||||||
class Converters {
|
class Converters {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import androidx.room.Insert
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||||
|
import com.github.damontecres.wholphin.data.model.ItemTrackModification
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
|
@ -26,4 +27,20 @@ interface ItemPlaybackDao {
|
||||||
|
|
||||||
@Query("SELECT * from ItemPlayback WHERE userId=:userId")
|
@Query("SELECT * from ItemPlayback WHERE userId=:userId")
|
||||||
fun getItems(userId: Int): List<ItemPlayback>
|
fun getItems(userId: Int): List<ItemPlayback>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM ItemTrackModification WHERE userId=:userId AND itemId=:itemId")
|
||||||
|
suspend fun getTrackModifications(
|
||||||
|
userId: Int,
|
||||||
|
itemId: UUID,
|
||||||
|
): List<ItemTrackModification>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM ItemTrackModification WHERE userId=:userId AND itemId=:itemId AND trackIndex=:trackIndex")
|
||||||
|
suspend fun getTrackModifications(
|
||||||
|
userId: Int,
|
||||||
|
itemId: UUID,
|
||||||
|
trackIndex: Int,
|
||||||
|
): ItemTrackModification?
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun saveItem(item: ItemTrackModification): Long
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,14 @@ package com.github.damontecres.wholphin.data
|
||||||
|
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||||
|
import com.github.damontecres.wholphin.data.model.ItemTrackModification
|
||||||
|
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
|
||||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||||
import com.github.damontecres.wholphin.data.model.chooseSource
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||||
import org.jellyfin.sdk.model.api.MediaStream
|
import org.jellyfin.sdk.model.api.MediaStream
|
||||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
|
|
@ -13,6 +17,7 @@ import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class ItemPlaybackRepository
|
class ItemPlaybackRepository
|
||||||
|
|
@ -20,47 +25,60 @@ class ItemPlaybackRepository
|
||||||
constructor(
|
constructor(
|
||||||
val serverRepository: ServerRepository,
|
val serverRepository: ServerRepository,
|
||||||
val itemPlaybackDao: ItemPlaybackDao,
|
val itemPlaybackDao: ItemPlaybackDao,
|
||||||
|
private val streamChoiceService: StreamChoiceService,
|
||||||
) {
|
) {
|
||||||
fun getSelectedTracks(
|
suspend fun getSelectedTracks(
|
||||||
itemId: UUID,
|
itemId: UUID,
|
||||||
item: BaseItem,
|
item: BaseItem,
|
||||||
|
prefs: UserPreferences,
|
||||||
): ChosenStreams? =
|
): ChosenStreams? =
|
||||||
serverRepository.currentUser.value?.let { user ->
|
serverRepository.currentUser.value?.let { user ->
|
||||||
val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId)
|
val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId)
|
||||||
if (itemPlayback != null) {
|
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
||||||
Timber.v("Got itemPlayback for %s", itemId)
|
Timber.v("For ${item.id}: itemPlayback=${itemPlayback != null}, plc=${plc != null}")
|
||||||
getChosenItemFromPlayback(item, itemPlayback)
|
return getChosenItemFromPlayback(item, itemPlayback, plc, prefs)
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getChosenItemFromPlayback(
|
fun getChosenItemFromPlayback(
|
||||||
item: BaseItem,
|
item: BaseItem,
|
||||||
itemPlayback: ItemPlayback,
|
itemPlayback: ItemPlayback?,
|
||||||
|
plc: PlaybackLanguageChoice?,
|
||||||
|
prefs: UserPreferences,
|
||||||
): ChosenStreams? {
|
): ChosenStreams? {
|
||||||
val source =
|
val source =
|
||||||
item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback.sourceId }
|
item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback?.sourceId }
|
||||||
|
?: streamChoiceService.chooseSource(item.data.mediaSources.orEmpty())
|
||||||
if (source != null) {
|
if (source != null) {
|
||||||
val audioStream =
|
val audioStream =
|
||||||
if (itemPlayback.audioIndexEnabled) {
|
streamChoiceService.chooseAudioStream(
|
||||||
source.mediaStreams?.firstOrNull { it.index == itemPlayback.audioIndex }
|
candidates =
|
||||||
} else {
|
source.mediaStreams
|
||||||
null
|
?.filter { it.type == MediaStreamType.AUDIO }
|
||||||
}
|
.orEmpty(),
|
||||||
|
itemPlayback = itemPlayback,
|
||||||
|
playbackLanguageChoice = plc,
|
||||||
|
prefs = prefs,
|
||||||
|
)
|
||||||
val subtitleStream =
|
val subtitleStream =
|
||||||
if (itemPlayback.subtitleIndexEnabled) {
|
streamChoiceService.chooseSubtitleStream(
|
||||||
source.mediaStreams?.firstOrNull { it.index == itemPlayback.subtitleIndex }
|
audioStream = audioStream,
|
||||||
} else {
|
candidates =
|
||||||
null
|
source.mediaStreams
|
||||||
}
|
?.filter { it.type == MediaStreamType.SUBTITLE }
|
||||||
|
.orEmpty(),
|
||||||
|
itemPlayback = itemPlayback,
|
||||||
|
playbackLanguageChoice = plc,
|
||||||
|
prefs = prefs,
|
||||||
|
)
|
||||||
return ChosenStreams(
|
return ChosenStreams(
|
||||||
itemPlayback,
|
itemPlayback = itemPlayback,
|
||||||
item.id,
|
plc = plc,
|
||||||
source.id?.toUUIDOrNull(),
|
itemId = item.id,
|
||||||
audioStream,
|
source = source,
|
||||||
subtitleStream,
|
videoStream = source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO },
|
||||||
itemPlayback.subtitleIndex == TrackIndex.DISABLED,
|
audioStream = audioStream,
|
||||||
|
subtitleStream = subtitleStream,
|
||||||
|
subtitlesDisabled = itemPlayback?.subtitleIndex == TrackIndex.DISABLED,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
return null
|
return null
|
||||||
|
|
@ -89,12 +107,21 @@ class ItemPlaybackRepository
|
||||||
itemPlayback: ItemPlayback?,
|
itemPlayback: ItemPlayback?,
|
||||||
trackIndex: Int,
|
trackIndex: Int,
|
||||||
type: MediaStreamType,
|
type: MediaStreamType,
|
||||||
) = serverRepository.currentUser.value?.let { user ->
|
): 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}")
|
||||||
|
}
|
||||||
var toSave =
|
var toSave =
|
||||||
itemPlayback ?: ItemPlayback(
|
itemPlayback ?: ItemPlayback(
|
||||||
userId = user.rowId,
|
userId = current.user.rowId,
|
||||||
itemId = item.id,
|
itemId = item.id,
|
||||||
sourceId = chooseSource(item.data, null)?.id?.toUUIDOrNull(),
|
sourceId = source.id?.toUUIDOrNull(),
|
||||||
)
|
)
|
||||||
toSave =
|
toSave =
|
||||||
when (type) {
|
when (type) {
|
||||||
|
|
@ -103,7 +130,34 @@ class ItemPlaybackRepository
|
||||||
else -> toSave
|
else -> toSave
|
||||||
}
|
}
|
||||||
Timber.v("Saving track selection %s", toSave)
|
Timber.v("Saving track selection %s", toSave)
|
||||||
saveItemPlayback(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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -124,12 +178,40 @@ class ItemPlaybackRepository
|
||||||
val id = itemPlaybackDao.saveItem(toSave)
|
val id = itemPlaybackDao.saveItem(toSave)
|
||||||
return toSave.copy(rowId = id)
|
return toSave.copy(rowId = id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun getTrackModifications(
|
||||||
|
itemId: UUID,
|
||||||
|
trackIndex: Int,
|
||||||
|
): ItemTrackModification? =
|
||||||
|
serverRepository.currentUser.value?.rowId?.let { userId ->
|
||||||
|
itemPlaybackDao.getTrackModifications(userId, itemId, trackIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveTrackModifications(
|
||||||
|
itemId: UUID,
|
||||||
|
trackIndex: Int,
|
||||||
|
delay: Duration,
|
||||||
|
) {
|
||||||
|
serverRepository.currentUser.value?.rowId?.let { userId ->
|
||||||
|
Timber.v("Saving track mod item=%s, track=%s, delay=%s", itemId, trackIndex, delay)
|
||||||
|
itemPlaybackDao.saveItem(
|
||||||
|
ItemTrackModification(
|
||||||
|
userId,
|
||||||
|
itemId,
|
||||||
|
trackIndex,
|
||||||
|
delay.inWholeMilliseconds,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ChosenStreams(
|
data class ChosenStreams(
|
||||||
val itemPlayback: ItemPlayback,
|
val itemPlayback: ItemPlayback?,
|
||||||
|
val plc: PlaybackLanguageChoice?,
|
||||||
val itemId: UUID,
|
val itemId: UUID,
|
||||||
val sourceId: UUID?,
|
val source: MediaSourceInfo,
|
||||||
|
val videoStream: MediaStream?,
|
||||||
val audioStream: MediaStream?,
|
val audioStream: MediaStream?,
|
||||||
val subtitleStream: MediaStream?,
|
val subtitleStream: MediaStream?,
|
||||||
val subtitlesDisabled: Boolean,
|
val subtitlesDisabled: Boolean,
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
if (serverAndUsers != null) {
|
||||||
val user = serverAndUsers.users.firstOrNull { it.id == userId }
|
val user = serverAndUsers.users.firstOrNull { it.id == userId }
|
||||||
if (user != null && !user.hasPin) {
|
if (user != null) {
|
||||||
|
// TODO pin-related
|
||||||
|
// if (user != null && !user.hasPin) {
|
||||||
changeUser(serverAndUsers.server, user)
|
changeUser(serverAndUsers.server, user)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
package com.github.damontecres.wholphin.data.model
|
package com.github.damontecres.wholphin.data.model
|
||||||
|
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.CardGridItem
|
||||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
||||||
import com.github.damontecres.wholphin.ui.formatDateTime
|
import com.github.damontecres.wholphin.ui.formatDateTime
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.playable
|
||||||
import com.github.damontecres.wholphin.ui.seasonEpisode
|
import com.github.damontecres.wholphin.ui.seasonEpisode
|
||||||
import com.github.damontecres.wholphin.ui.seasonEpisodePadded
|
import com.github.damontecres.wholphin.ui.seasonEpisodePadded
|
||||||
import com.github.damontecres.wholphin.ui.seriesProductionYears
|
import com.github.damontecres.wholphin.ui.seriesProductionYears
|
||||||
|
|
@ -18,8 +20,14 @@ import kotlin.time.Duration
|
||||||
data class BaseItem(
|
data class BaseItem(
|
||||||
val data: BaseItemDto,
|
val data: BaseItemDto,
|
||||||
val useSeriesForPrimary: Boolean,
|
val useSeriesForPrimary: Boolean,
|
||||||
) {
|
) : CardGridItem {
|
||||||
val id get() = data.id
|
override val id get() = data.id
|
||||||
|
|
||||||
|
override val playable: Boolean
|
||||||
|
get() = type.playable
|
||||||
|
|
||||||
|
override val sortName: String
|
||||||
|
get() = data.sortName ?: data.name ?: ""
|
||||||
|
|
||||||
val type get() = data.type
|
val type get() = data.type
|
||||||
|
|
||||||
|
|
@ -47,6 +55,9 @@ data class BaseItem(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transient
|
||||||
|
val aspectRatio: Float? = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 }
|
||||||
|
|
||||||
@Transient
|
@Transient
|
||||||
val indexNumber = data.indexNumber ?: dateAsIndex()
|
val indexNumber = data.indexNumber ?: dateAsIndex()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,19 +7,10 @@ import androidx.room.ForeignKey
|
||||||
import androidx.room.Ignore
|
import androidx.room.Ignore
|
||||||
import androidx.room.Index
|
import androidx.room.Index
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
|
||||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.Transient
|
import kotlinx.serialization.Transient
|
||||||
import kotlinx.serialization.UseSerializers
|
import kotlinx.serialization.UseSerializers
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
|
||||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
|
||||||
import org.jellyfin.sdk.model.api.MediaStream
|
|
||||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
|
||||||
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
|
|
||||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
|
|
@ -53,122 +44,6 @@ data class ItemPlayback(
|
||||||
val subtitleIndexEnabled = subtitleIndex >= 0
|
val subtitleIndexEnabled = subtitleIndex >= 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the [MediaSourceInfo] with the highest video resolution
|
|
||||||
*/
|
|
||||||
fun chooseSource(sources: List<MediaSourceInfo>?) =
|
|
||||||
sources?.letNotEmpty { sources ->
|
|
||||||
val result =
|
|
||||||
sources.maxByOrNull { s ->
|
|
||||||
s.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }?.let { video ->
|
|
||||||
(video.width ?: 0) * (video.height ?: 0)
|
|
||||||
} ?: 0
|
|
||||||
}
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the [MediaSourceInfo] that matched the [ItemPlayback] or else the one with the highest resolution
|
|
||||||
*/
|
|
||||||
fun chooseSource(
|
|
||||||
dto: BaseItemDto,
|
|
||||||
itemPlayback: ItemPlayback?,
|
|
||||||
) = itemPlayback?.sourceId?.let { dto.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback.sourceId } }
|
|
||||||
?: chooseSource(dto.mediaSources) // dto.mediaSources?.firstOrNull()
|
|
||||||
|
|
||||||
fun chooseStream(
|
|
||||||
dto: BaseItemDto,
|
|
||||||
itemPlayback: ItemPlayback?,
|
|
||||||
type: MediaStreamType,
|
|
||||||
prefs: UserPreferences,
|
|
||||||
): MediaStream? {
|
|
||||||
val source = chooseSource(dto, itemPlayback)
|
|
||||||
return source?.mediaStreams?.letNotEmpty { streams ->
|
|
||||||
val candidates = streams.filter { it.type == type }
|
|
||||||
when (type) {
|
|
||||||
MediaStreamType.AUDIO -> chooseAudioStream(candidates, itemPlayback, prefs)
|
|
||||||
MediaStreamType.SUBTITLE -> chooseSubtitleStream(candidates, itemPlayback, prefs)
|
|
||||||
else -> candidates.firstOrNull()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun chooseAudioStream(
|
|
||||||
candidates: List<MediaStream>,
|
|
||||||
itemPlayback: ItemPlayback?,
|
|
||||||
prefs: UserPreferences,
|
|
||||||
): MediaStream? =
|
|
||||||
if (itemPlayback?.audioIndexEnabled == true) {
|
|
||||||
candidates.firstOrNull { it.index == itemPlayback.audioIndex }
|
|
||||||
} else {
|
|
||||||
// TODO audio selection based on channel layout or preferences or default
|
|
||||||
val audioLanguage = prefs.userConfig.audioLanguagePreference
|
|
||||||
if (audioLanguage.isNotNullOrBlank()) {
|
|
||||||
val sorted =
|
|
||||||
candidates.sortedWith(compareBy<MediaStream> { it.language }.thenByDescending { it.channels })
|
|
||||||
sorted.firstOrNull { it.language == audioLanguage && it.isDefault }
|
|
||||||
?: sorted.firstOrNull { it.language == audioLanguage }
|
|
||||||
?: sorted.firstOrNull { it.isDefault }
|
|
||||||
?: sorted.firstOrNull()
|
|
||||||
} else {
|
|
||||||
candidates.firstOrNull { it.isDefault }
|
|
||||||
?: candidates.firstOrNull()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun chooseSubtitleStream(
|
|
||||||
candidates: List<MediaStream>,
|
|
||||||
itemPlayback: ItemPlayback?,
|
|
||||||
prefs: UserPreferences,
|
|
||||||
): MediaStream? {
|
|
||||||
if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) {
|
|
||||||
return null
|
|
||||||
} else if (itemPlayback?.subtitleIndexEnabled == true) {
|
|
||||||
return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex }
|
|
||||||
} else {
|
|
||||||
val subtitleLanguage = prefs.userConfig.subtitleLanguagePreference
|
|
||||||
return when (prefs.userConfig.subtitleMode) {
|
|
||||||
SubtitlePlaybackMode.ALWAYS -> {
|
|
||||||
if (subtitleLanguage != null) {
|
|
||||||
candidates.firstOrNull { it.language == subtitleLanguage }
|
|
||||||
} else {
|
|
||||||
candidates.firstOrNull()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SubtitlePlaybackMode.ONLY_FORCED -> {
|
|
||||||
if (subtitleLanguage != null) {
|
|
||||||
candidates.firstOrNull { it.language == subtitleLanguage && it.isForced }
|
|
||||||
} else {
|
|
||||||
candidates.firstOrNull { it.isForced }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SubtitlePlaybackMode.SMART -> {
|
|
||||||
val audioLanguage = prefs.userConfig.audioLanguagePreference
|
|
||||||
if (audioLanguage != null && subtitleLanguage != null && audioLanguage != subtitleLanguage) {
|
|
||||||
candidates.firstOrNull { it.language == subtitleLanguage }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SubtitlePlaybackMode.DEFAULT -> {
|
|
||||||
// TODO check for language?
|
|
||||||
(
|
|
||||||
candidates.firstOrNull { it.isDefault && it.isForced }
|
|
||||||
?: candidates.firstOrNull { it.isDefault }
|
|
||||||
?: candidates.firstOrNull { it.isForced }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
SubtitlePlaybackMode.NONE -> {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
object TrackIndex {
|
object TrackIndex {
|
||||||
/**
|
/**
|
||||||
* The user has not explicitly specified a track to use
|
* The user has not explicitly specified a track to use
|
||||||
|
|
|
||||||
|
|
@ -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,
|
summaryOff = R.string.disabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val BackdropStylePref =
|
||||||
|
AppChoicePreference<AppPreferences, BackdropStyle>(
|
||||||
|
title = R.string.backdrop_display,
|
||||||
|
defaultValue = BackdropStyle.BACKDROP_DYNAMIC_COLOR,
|
||||||
|
getter = { it.interfacePreferences.backdropStyle },
|
||||||
|
setter = { prefs, value ->
|
||||||
|
prefs.updateInterfacePreferences { backdropStyle = value }
|
||||||
|
},
|
||||||
|
displayValues = R.array.backdrop_style_options,
|
||||||
|
indexToValue = { BackdropStyle.forNumber(it) },
|
||||||
|
valueToIndex = { it.number },
|
||||||
|
)
|
||||||
|
|
||||||
val OneClickPause =
|
val OneClickPause =
|
||||||
AppSwitchPreference<AppPreferences>(
|
AppSwitchPreference<AppPreferences>(
|
||||||
title = R.string.one_click_pause,
|
title = R.string.one_click_pause,
|
||||||
|
|
@ -784,6 +797,51 @@ sealed interface AppPreference<Pref, T> {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val LiveTvShowHeader =
|
||||||
|
AppSwitchPreference<AppPreferences>(
|
||||||
|
title = R.string.show_details,
|
||||||
|
defaultValue = true,
|
||||||
|
getter = { it.interfacePreferences.liveTvPreferences.showHeader },
|
||||||
|
setter = { prefs, value ->
|
||||||
|
prefs.updateLiveTvPreferences { showHeader = value }
|
||||||
|
},
|
||||||
|
summaryOn = R.string.enabled,
|
||||||
|
summaryOff = R.string.disabled,
|
||||||
|
)
|
||||||
|
val LiveTvFavoriteChannelsBeginning =
|
||||||
|
AppSwitchPreference<AppPreferences>(
|
||||||
|
title = R.string.favorite_channels_at_beginning,
|
||||||
|
defaultValue = true,
|
||||||
|
getter = { it.interfacePreferences.liveTvPreferences.favoriteChannelsAtBeginning },
|
||||||
|
setter = { prefs, value ->
|
||||||
|
prefs.updateLiveTvPreferences { favoriteChannelsAtBeginning = value }
|
||||||
|
},
|
||||||
|
summaryOn = R.string.enabled,
|
||||||
|
summaryOff = R.string.disabled,
|
||||||
|
)
|
||||||
|
val LiveTvChannelSortByWatched =
|
||||||
|
AppSwitchPreference<AppPreferences>(
|
||||||
|
title = R.string.sort_channels_recently_watched,
|
||||||
|
defaultValue = false,
|
||||||
|
getter = { it.interfacePreferences.liveTvPreferences.sortByRecentlyWatched },
|
||||||
|
setter = { prefs, value ->
|
||||||
|
prefs.updateLiveTvPreferences { sortByRecentlyWatched = value }
|
||||||
|
},
|
||||||
|
summaryOn = R.string.enabled,
|
||||||
|
summaryOff = R.string.disabled,
|
||||||
|
)
|
||||||
|
val LiveTvColorCodePrograms =
|
||||||
|
AppSwitchPreference<AppPreferences>(
|
||||||
|
title = R.string.color_code_programs,
|
||||||
|
defaultValue = true,
|
||||||
|
getter = { it.interfacePreferences.liveTvPreferences.colorCodePrograms },
|
||||||
|
setter = { prefs, value ->
|
||||||
|
prefs.updateLiveTvPreferences { colorCodePrograms = value }
|
||||||
|
},
|
||||||
|
summaryOn = R.string.enabled,
|
||||||
|
summaryOff = R.string.disabled,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -864,6 +922,7 @@ val advancedPreferences =
|
||||||
// Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418
|
// Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418
|
||||||
// AppPreference.NavDrawerSwitchOnFocus,
|
// AppPreference.NavDrawerSwitchOnFocus,
|
||||||
AppPreference.ControllerTimeout,
|
AppPreference.ControllerTimeout,
|
||||||
|
AppPreference.BackdropStylePref,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -948,6 +1007,14 @@ val advancedPreferences =
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val liveTvPreferences =
|
||||||
|
listOf(
|
||||||
|
AppPreference.LiveTvShowHeader,
|
||||||
|
AppPreference.LiveTvFavoriteChannelsBeginning,
|
||||||
|
AppPreference.LiveTvChannelSortByWatched,
|
||||||
|
AppPreference.LiveTvColorCodePrograms,
|
||||||
|
)
|
||||||
|
|
||||||
data class AppSwitchPreference<Pref>(
|
data class AppSwitchPreference<Pref>(
|
||||||
@get:StringRes override val title: Int,
|
@get:StringRes override val title: Int,
|
||||||
override val defaultValue: Boolean,
|
override val defaultValue: Boolean,
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,7 @@ class AppPreferencesSerializer
|
||||||
navDrawerSwitchOnFocus =
|
navDrawerSwitchOnFocus =
|
||||||
AppPreference.NavDrawerSwitchOnFocus.defaultValue
|
AppPreference.NavDrawerSwitchOnFocus.defaultValue
|
||||||
showClock = AppPreference.ShowClock.defaultValue
|
showClock = AppPreference.ShowClock.defaultValue
|
||||||
|
backdropStyle = AppPreference.BackdropStylePref.defaultValue
|
||||||
|
|
||||||
subtitlesPreferences =
|
subtitlesPreferences =
|
||||||
SubtitlePreferences
|
SubtitlePreferences
|
||||||
|
|
@ -98,6 +99,19 @@ class AppPreferencesSerializer
|
||||||
.apply {
|
.apply {
|
||||||
resetSubtitles()
|
resetSubtitles()
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
|
liveTvPreferences =
|
||||||
|
LiveTvPreferences
|
||||||
|
.newBuilder()
|
||||||
|
.apply {
|
||||||
|
showHeader = AppPreference.LiveTvShowHeader.defaultValue
|
||||||
|
favoriteChannelsAtBeginning =
|
||||||
|
AppPreference.LiveTvFavoriteChannelsBeginning.defaultValue
|
||||||
|
sortByRecentlyWatched =
|
||||||
|
AppPreference.LiveTvChannelSortByWatched.defaultValue
|
||||||
|
colorCodePrograms =
|
||||||
|
AppPreference.LiveTvColorCodePrograms.defaultValue
|
||||||
|
}.build()
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
advancedPreferences =
|
advancedPreferences =
|
||||||
|
|
@ -155,6 +169,11 @@ inline fun AppPreferences.updateSubtitlePreferences(block: SubtitlePreferences.B
|
||||||
subtitlesPreferences = subtitlesPreferences.toBuilder().apply(block).build()
|
subtitlesPreferences = subtitlesPreferences.toBuilder().apply(block).build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inline fun AppPreferences.updateLiveTvPreferences(block: LiveTvPreferences.Builder.() -> Unit): AppPreferences =
|
||||||
|
updateInterfacePreferences {
|
||||||
|
liveTvPreferences = liveTvPreferences.toBuilder().apply(block).build()
|
||||||
|
}
|
||||||
|
|
||||||
inline fun AppPreferences.updateAdvancedPreferences(block: AdvancedPreferences.Builder.() -> Unit): AppPreferences =
|
inline fun AppPreferences.updateAdvancedPreferences(block: AdvancedPreferences.Builder.() -> Unit): AppPreferences =
|
||||||
update {
|
update {
|
||||||
advancedPreferences = advancedPreferences.toBuilder().apply(block).build()
|
advancedPreferences = advancedPreferences.toBuilder().apply(block).build()
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.update
|
import com.github.damontecres.wholphin.preferences.update
|
||||||
import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences
|
import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
|
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
|
||||||
|
import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.updateMpvOptions
|
import com.github.damontecres.wholphin.preferences.updateMpvOptions
|
||||||
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
|
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
|
||||||
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
|
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
|
||||||
|
|
@ -173,4 +174,27 @@ suspend fun upgradeApp(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (previous.isEqualOrBefore(Version.fromString("0.3.5-56-g0"))) {
|
||||||
|
appPreferences.updateData {
|
||||||
|
it.updateLiveTvPreferences {
|
||||||
|
showHeader = AppPreference.LiveTvShowHeader.defaultValue
|
||||||
|
favoriteChannelsAtBeginning =
|
||||||
|
AppPreference.LiveTvFavoriteChannelsBeginning.defaultValue
|
||||||
|
sortByRecentlyWatched =
|
||||||
|
AppPreference.LiveTvChannelSortByWatched.defaultValue
|
||||||
|
colorCodePrograms =
|
||||||
|
AppPreference.LiveTvColorCodePrograms.defaultValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previous.isEqualOrBefore(Version.fromString("0.3.6-52-g0"))) {
|
||||||
|
if (Build.MODEL.equals("shield android tv", ignoreCase = true)) {
|
||||||
|
appPreferences.updateData {
|
||||||
|
it.updateMpvOptions {
|
||||||
|
useGpuNext = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
imageType: ImageType,
|
||||||
fillWidth: Int? = null,
|
fillWidth: Int? = null,
|
||||||
fillHeight: Int? = null,
|
fillHeight: Int? = null,
|
||||||
): String? =
|
): String =
|
||||||
when (imageType) {
|
when (imageType) {
|
||||||
ImageType.BACKDROP,
|
ImageType.BACKDROP,
|
||||||
ImageType.LOGO,
|
ImageType.LOGO,
|
||||||
|
|
@ -145,6 +145,8 @@ class ImageUrlService
|
||||||
imageIndex = imageIndex,
|
imageIndex = imageIndex,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun getUserImageUrl(userId: UUID) = api.imageApi.getUserImageUrl(userId)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Just a convenient way to get the image URL and remember it
|
* Just a convenient way to get the image URL and remember it
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,12 @@ package com.github.damontecres.wholphin.services
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.hardware.display.DisplayManager
|
import android.hardware.display.DisplayManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.view.Display
|
import android.view.Display
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||||
|
import com.github.damontecres.wholphin.ui.showToast
|
||||||
import com.github.damontecres.wholphin.util.EqualityMutableLiveData
|
import com.github.damontecres.wholphin.util.EqualityMutableLiveData
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
@ -29,6 +32,8 @@ class RefreshRateService
|
||||||
private val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
|
private val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
|
||||||
private val originalMode = display.mode
|
private val originalMode = display.mode
|
||||||
|
|
||||||
|
val displayModes get() = display.supportedModes
|
||||||
|
|
||||||
private val _refreshRateMode = EqualityMutableLiveData<Display.Mode>(originalMode)
|
private val _refreshRateMode = EqualityMutableLiveData<Display.Mode>(originalMode)
|
||||||
val refreshRateMode: LiveData<Display.Mode> = _refreshRateMode
|
val refreshRateMode: LiveData<Display.Mode> = _refreshRateMode
|
||||||
|
|
||||||
|
|
@ -57,12 +62,18 @@ class RefreshRateService
|
||||||
Timber.i("Found display mode: %s, current=${display.mode}", targetMode)
|
Timber.i("Found display mode: %s, current=${display.mode}", targetMode)
|
||||||
if (targetMode != null && targetMode != display.mode) {
|
if (targetMode != null && targetMode != display.mode) {
|
||||||
val listener = Listener(display.displayId)
|
val listener = Listener(display.displayId)
|
||||||
displayManager.registerDisplayListener(listener, null)
|
displayManager.registerDisplayListener(
|
||||||
|
listener,
|
||||||
|
Handler(Looper.myLooper() ?: Looper.getMainLooper()),
|
||||||
|
)
|
||||||
_refreshRateMode.setValueOnMain(targetMode)
|
_refreshRateMode.setValueOnMain(targetMode)
|
||||||
try {
|
try {
|
||||||
listener.latch.await(5, TimeUnit.SECONDS)
|
if (!listener.latch.await(5, TimeUnit.SECONDS)) {
|
||||||
} catch (_: InterruptedException) {
|
|
||||||
Timber.w("Timed out waiting for display change")
|
Timber.w("Timed out waiting for display change")
|
||||||
|
showToast(context, "Refresh rate switch is taking a long time")
|
||||||
|
}
|
||||||
|
} catch (ex: InterruptedException) {
|
||||||
|
Timber.w(ex, "Exception waiting for refresh rate switch")
|
||||||
}
|
}
|
||||||
val targetRate = (targetMode.refreshRate * 100).roundToInt()
|
val targetRate = (targetMode.refreshRate * 100).roundToInt()
|
||||||
val isSeamless =
|
val isSeamless =
|
||||||
|
|
|
||||||
|
|
@ -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
|
package com.github.damontecres.wholphin.services.hilt
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.provider.Settings
|
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import com.github.damontecres.wholphin.BuildConfig
|
import com.github.damontecres.wholphin.BuildConfig
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
|
|
@ -24,6 +22,7 @@ import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.jellyfin.sdk.Jellyfin
|
import org.jellyfin.sdk.Jellyfin
|
||||||
|
import org.jellyfin.sdk.android.androidDevice
|
||||||
import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder
|
import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder
|
||||||
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
|
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
|
||||||
import org.jellyfin.sdk.createJellyfin
|
import org.jellyfin.sdk.createJellyfin
|
||||||
|
|
@ -61,14 +60,7 @@ object AppModule {
|
||||||
@Singleton
|
@Singleton
|
||||||
fun deviceInfo(
|
fun deviceInfo(
|
||||||
@ApplicationContext context: Context,
|
@ApplicationContext context: Context,
|
||||||
): DeviceInfo =
|
): DeviceInfo = androidDevice(context)
|
||||||
DeviceInfo(
|
|
||||||
id = @SuppressLint("HardwareIds") Settings.Secure.getString(
|
|
||||||
context.contentResolver,
|
|
||||||
Settings.Secure.ANDROID_ID,
|
|
||||||
),
|
|
||||||
name = Settings.Global.getString(context.contentResolver, Settings.Global.DEVICE_NAME),
|
|
||||||
)
|
|
||||||
|
|
||||||
@StandardOkHttpClient
|
@StandardOkHttpClient
|
||||||
@Provides
|
@Provides
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.data.ItemPlaybackDao
|
||||||
import com.github.damontecres.wholphin.data.JellyfinServerDao
|
import com.github.damontecres.wholphin.data.JellyfinServerDao
|
||||||
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
|
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
|
||||||
import com.github.damontecres.wholphin.data.Migrations
|
import com.github.damontecres.wholphin.data.Migrations
|
||||||
|
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
|
||||||
import com.github.damontecres.wholphin.data.ServerPreferencesDao
|
import com.github.damontecres.wholphin.data.ServerPreferencesDao
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
|
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
|
||||||
|
|
@ -56,6 +57,10 @@ object DatabaseModule {
|
||||||
@Singleton
|
@Singleton
|
||||||
fun libraryDisplayInfoDao(db: AppDatabase): LibraryDisplayInfoDao = db.libraryDisplayInfoDao()
|
fun libraryDisplayInfoDao(db: AppDatabase): LibraryDisplayInfoDao = db.libraryDisplayInfoDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun playbackLanguageChoiceDao(db: AppDatabase): PlaybackLanguageChoiceDao = db.playbackLanguageChoiceDao()
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun userPreferencesDataStore(
|
fun userPreferencesDataStore(
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,12 @@ import coil3.compose.setSingletonImageLoaderFactory
|
||||||
import coil3.disk.DiskCache
|
import coil3.disk.DiskCache
|
||||||
import coil3.disk.directory
|
import coil3.disk.directory
|
||||||
import coil3.memory.MemoryCache
|
import coil3.memory.MemoryCache
|
||||||
|
import coil3.network.CacheStrategy
|
||||||
|
import coil3.network.NetworkRequest
|
||||||
|
import coil3.network.NetworkResponse
|
||||||
import coil3.network.cachecontrol.CacheControlCacheStrategy
|
import coil3.network.cachecontrol.CacheControlCacheStrategy
|
||||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||||
|
import coil3.request.Options
|
||||||
import coil3.request.crossfade
|
import coil3.request.crossfade
|
||||||
import coil3.util.DebugLogger
|
import coil3.util.DebugLogger
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
|
@ -67,10 +71,39 @@ fun CoilConfig(
|
||||||
.components {
|
.components {
|
||||||
add(
|
add(
|
||||||
OkHttpNetworkFetcherFactory(
|
OkHttpNetworkFetcherFactory(
|
||||||
cacheStrategy = { CacheControlCacheStrategy() },
|
cacheStrategy = { WholphinCacheStrategy(CacheControlCacheStrategy()) },
|
||||||
callFactory = { client },
|
callFactory = { client },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}.build()
|
}.build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This [CacheStrategy] always prefers the cached response for Trickplay images,
|
||||||
|
* otherwise the decision is delegated to the provided [CacheStrategy]
|
||||||
|
*
|
||||||
|
* The expectation is that Trickplay images will be prefetched so the cache will always be warm
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoilApi::class)
|
||||||
|
private class WholphinCacheStrategy(
|
||||||
|
private val delegate: CacheStrategy,
|
||||||
|
) : CacheStrategy {
|
||||||
|
override suspend fun read(
|
||||||
|
cacheResponse: NetworkResponse,
|
||||||
|
networkRequest: NetworkRequest,
|
||||||
|
options: Options,
|
||||||
|
): CacheStrategy.ReadResult =
|
||||||
|
if (networkRequest.url.contains("/Trickplay/")) {
|
||||||
|
CacheStrategy.ReadResult(cacheResponse)
|
||||||
|
} else {
|
||||||
|
delegate.read(cacheResponse, networkRequest, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun write(
|
||||||
|
cacheResponse: NetworkResponse?,
|
||||||
|
networkRequest: NetworkRequest,
|
||||||
|
networkResponse: NetworkResponse,
|
||||||
|
options: Options,
|
||||||
|
): CacheStrategy.WriteResult = delegate.write(cacheResponse, networkRequest, networkResponse, options)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ val SlimItemFields =
|
||||||
object Cards {
|
object Cards {
|
||||||
val height2x3 = 172.dp
|
val height2x3 = 172.dp
|
||||||
val playedPercentHeight = 6.dp
|
val playedPercentHeight = 6.dp
|
||||||
|
val serverUserCircle = height2x3 * .75f
|
||||||
}
|
}
|
||||||
|
|
||||||
object AspectRatios {
|
object AspectRatios {
|
||||||
|
|
@ -75,6 +76,8 @@ object AspectRatios {
|
||||||
const val FOUR_THREE = 4f / 3f
|
const val FOUR_THREE = 4f / 3f
|
||||||
const val TALL = 2f / 3f
|
const val TALL = 2f / 3f
|
||||||
const val SQUARE = 1f
|
const val SQUARE = 1f
|
||||||
|
|
||||||
|
const val MIN = TALL
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class AspectRatio(
|
enum class AspectRatio(
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ fun EpisodeCard(
|
||||||
} else {
|
} else {
|
||||||
focusedAfterDelay = false
|
focusedAfterDelay = false
|
||||||
}
|
}
|
||||||
val aspectRatio = dto?.primaryImageAspectRatio?.toFloat() ?: AspectRatios.TALL
|
val aspectRatio = item?.aspectRatio?.coerceAtLeast(AspectRatios.MIN) ?: AspectRatios.MIN
|
||||||
val width = imageHeight * aspectRatio
|
val width = imageHeight * aspectRatio
|
||||||
val height = imageWidth * (1f / aspectRatio)
|
val height = imageWidth * (1f / aspectRatio)
|
||||||
Column(
|
Column(
|
||||||
|
|
|
||||||
|
|
@ -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
|
package com.github.damontecres.wholphin.ui.cards
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
|
@ -44,6 +45,7 @@ fun GridCard(
|
||||||
imageAspectRatio: Float = AspectRatios.TALL,
|
imageAspectRatio: Float = AspectRatios.TALL,
|
||||||
imageContentScale: ContentScale = ContentScale.Fit,
|
imageContentScale: ContentScale = ContentScale.Fit,
|
||||||
imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
|
imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
|
||||||
|
showTitle: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val dto = item?.data
|
val dto = item?.data
|
||||||
val focused by interactionSource.collectIsFocusedAsState()
|
val focused by interactionSource.collectIsFocusedAsState()
|
||||||
|
|
@ -94,10 +96,11 @@ fun GridCard(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.aspectRatio(imageAspectRatio)
|
.aspectRatio(imageAspectRatio.coerceAtLeast(AspectRatios.MIN))
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
AnimatedVisibility(showTitle) {
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||||
modifier =
|
modifier =
|
||||||
|
|
@ -129,3 +132,4 @@ fun GridCard(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -113,8 +113,7 @@ fun BannerItemRow(
|
||||||
name = title,
|
name = title,
|
||||||
item = item,
|
item = item,
|
||||||
aspectRatio =
|
aspectRatio =
|
||||||
aspectRatioOverride ?: item?.data?.primaryImageAspectRatio?.toFloat()
|
aspectRatioOverride ?: item?.aspectRatio ?: AspectRatios.WIDE,
|
||||||
?: AspectRatios.WIDE,
|
|
||||||
cornerText = item?.data?.indexNumber?.let { "E$it" },
|
cornerText = item?.data?.indexNumber?.let { "E$it" },
|
||||||
played = item?.data?.userData?.played ?: false,
|
played = item?.data?.userData?.played ?: false,
|
||||||
playPercent = item?.data?.userData?.playedPercentage ?: 0.0,
|
playPercent = item?.data?.userData?.playedPercentage ?: 0.0,
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ fun SeasonCard(
|
||||||
imageWidth: Dp = Dp.Unspecified,
|
imageWidth: Dp = Dp.Unspecified,
|
||||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
showImageOverlay: Boolean = false,
|
showImageOverlay: Boolean = false,
|
||||||
aspectRatio: Float = item?.data?.primaryImageAspectRatio?.toFloat() ?: AspectRatios.TALL,
|
aspectRatio: Float = item?.aspectRatio ?: AspectRatios.TALL,
|
||||||
) {
|
) {
|
||||||
val imageUrlService = LocalImageUrlService.current
|
val imageUrlService = LocalImageUrlService.current
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
|
@ -139,8 +139,9 @@ fun SeasonCard(
|
||||||
} else {
|
} else {
|
||||||
focusedAfterDelay = false
|
focusedAfterDelay = false
|
||||||
}
|
}
|
||||||
val width = imageHeight * aspectRatio
|
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
|
||||||
val height = imageWidth * (1f / aspectRatio)
|
val width = imageHeight * aspectRationToUse
|
||||||
|
val height = imageWidth * (1f / aspectRationToUse)
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(spaceBetween),
|
verticalArrangement = Arrangement.spacedBy(spaceBetween),
|
||||||
modifier = modifier.size(width, height),
|
modifier = modifier.size(width, height),
|
||||||
|
|
@ -149,7 +150,7 @@ fun SeasonCard(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.size(imageWidth, imageHeight)
|
.size(imageWidth, imageHeight)
|
||||||
.aspectRatio(aspectRatio),
|
.aspectRatio(aspectRationToUse),
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||||
import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride
|
import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride
|
||||||
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
|
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||||
|
|
@ -76,6 +77,7 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
||||||
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.main.HomePageHeader
|
import com.github.damontecres.wholphin.ui.main.HomePageHeader
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.playback.scale
|
import com.github.damontecres.wholphin.ui.playback.scale
|
||||||
|
|
@ -123,6 +125,7 @@ class CollectionFolderViewModel
|
||||||
private val serverRepository: ServerRepository,
|
private val serverRepository: ServerRepository,
|
||||||
private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
|
private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
|
||||||
private val favoriteWatchManager: FavoriteWatchManager,
|
private val favoriteWatchManager: FavoriteWatchManager,
|
||||||
|
private val backdropService: BackdropService,
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
) : ItemViewModel(api) {
|
) : ItemViewModel(api) {
|
||||||
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
||||||
|
|
@ -202,6 +205,9 @@ class CollectionFolderViewModel
|
||||||
this.viewOptions.value = viewOptions
|
this.viewOptions.value = viewOptions
|
||||||
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||||
saveLibraryDisplayInfo(viewOptions = viewOptions)
|
saveLibraryDisplayInfo(viewOptions = viewOptions)
|
||||||
|
if (!viewOptions.showDetails) {
|
||||||
|
backdropService.clearBackdrop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -246,14 +252,27 @@ class CollectionFolderViewModel
|
||||||
this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection
|
this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection
|
||||||
this@CollectionFolderViewModel.filter.value = filter
|
this@CollectionFolderViewModel.filter.value = filter
|
||||||
}
|
}
|
||||||
val newPager = createPager(sortAndDirection, recursive, filter, useSeriesForPrimary)
|
try {
|
||||||
newPager.init()
|
val newPager =
|
||||||
|
createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init()
|
||||||
if (newPager.isNotEmpty()) newPager.getBlocking(0)
|
if (newPager.isNotEmpty()) newPager.getBlocking(0)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
pager.value = newPager
|
pager.value = newPager
|
||||||
loading.value = LoadingState.Success
|
loading.value = LoadingState.Success
|
||||||
backgroundLoading.value = LoadingState.Success
|
backgroundLoading.value = LoadingState.Success
|
||||||
}
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(
|
||||||
|
ex,
|
||||||
|
"Exception while loading data: sort=%s, filter=%s",
|
||||||
|
sortAndDirection,
|
||||||
|
filter,
|
||||||
|
)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
loading.value = LoadingState.Error(ex)
|
||||||
|
pager.value = listOf()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -454,6 +473,12 @@ class CollectionFolderViewModel
|
||||||
favoriteWatchManager.setFavorite(itemId, favorite)
|
favoriteWatchManager.setFavorite(itemId, favorite)
|
||||||
(pager.value as? ApiRequestPager<*>)?.refreshItem(position, itemId)
|
(pager.value as? ApiRequestPager<*>)?.refreshItem(position, itemId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun updateBackdrop(item: BaseItem) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
backdropService.submit(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -505,7 +530,7 @@ fun CollectionFolderGrid(
|
||||||
playEnabled: Boolean,
|
playEnabled: Boolean,
|
||||||
defaultViewOptions: ViewOptions,
|
defaultViewOptions: ViewOptions,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: CollectionFolderViewModel = hiltViewModel(),
|
viewModel: CollectionFolderViewModel = hiltViewModel(key = itemId),
|
||||||
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||||
initialSortAndDirection: SortAndDirection? = null,
|
initialSortAndDirection: SortAndDirection? = null,
|
||||||
showTitle: Boolean = true,
|
showTitle: Boolean = true,
|
||||||
|
|
@ -578,8 +603,12 @@ fun CollectionFolderGrid(
|
||||||
viewOptions = viewOptions,
|
viewOptions = viewOptions,
|
||||||
defaultViewOptions = defaultViewOptions,
|
defaultViewOptions = defaultViewOptions,
|
||||||
onSaveViewOptions = { viewModel.saveViewOptions(it) },
|
onSaveViewOptions = { viewModel.saveViewOptions(it) },
|
||||||
|
onChangeBackdrop = viewModel::updateBackdrop,
|
||||||
playEnabled = playEnabled,
|
playEnabled = playEnabled,
|
||||||
onClickPlay = { shuffle ->
|
onClickPlay = { _, item ->
|
||||||
|
viewModel.navigationManager.navigateTo(Destination.Playback(item))
|
||||||
|
},
|
||||||
|
onClickPlayAll = { shuffle ->
|
||||||
itemId.toUUIDOrNull()?.let {
|
itemId.toUUIDOrNull()?.let {
|
||||||
viewModel.navigationManager.navigateTo(
|
viewModel.navigationManager.navigateTo(
|
||||||
Destination.PlaybackList(
|
Destination.PlaybackList(
|
||||||
|
|
@ -678,17 +707,19 @@ fun CollectionFolderGridContent(
|
||||||
letterPosition: suspend (Char) -> Int,
|
letterPosition: suspend (Char) -> Int,
|
||||||
sortOptions: List<ItemSortBy>,
|
sortOptions: List<ItemSortBy>,
|
||||||
playEnabled: Boolean,
|
playEnabled: Boolean,
|
||||||
onClickPlay: (shuffle: Boolean) -> Unit,
|
getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>,
|
||||||
|
defaultViewOptions: ViewOptions,
|
||||||
|
onSaveViewOptions: (ViewOptions) -> Unit,
|
||||||
|
viewOptions: ViewOptions,
|
||||||
|
onClickPlayAll: (shuffle: Boolean) -> Unit,
|
||||||
|
onClickPlay: (Int, BaseItem) -> Unit,
|
||||||
|
onChangeBackdrop: (BaseItem) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
showTitle: Boolean = true,
|
showTitle: Boolean = true,
|
||||||
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
||||||
currentFilter: GetItemsFilter = GetItemsFilter(),
|
currentFilter: GetItemsFilter = GetItemsFilter(),
|
||||||
filterOptions: List<ItemFilterBy<*>> = listOf(),
|
filterOptions: List<ItemFilterBy<*>> = listOf(),
|
||||||
onFilterChange: (GetItemsFilter) -> Unit = {},
|
onFilterChange: (GetItemsFilter) -> Unit = {},
|
||||||
getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>,
|
|
||||||
defaultViewOptions: ViewOptions,
|
|
||||||
viewOptions: ViewOptions,
|
|
||||||
onSaveViewOptions: (ViewOptions) -> Unit,
|
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val title = item?.name ?: item?.data?.collectionType?.name ?: stringResource(R.string.collection)
|
val title = item?.name ?: item?.data?.collectionType?.name ?: stringResource(R.string.collection)
|
||||||
|
|
@ -703,14 +734,13 @@ fun CollectionFolderGridContent(
|
||||||
|
|
||||||
var position by rememberInt(0)
|
var position by rememberInt(0)
|
||||||
val focusedItem = pager.getOrNull(position)
|
val focusedItem = pager.getOrNull(position)
|
||||||
|
if (viewOptions.showDetails) {
|
||||||
|
LaunchedEffect(focusedItem) {
|
||||||
|
focusedItem?.let(onChangeBackdrop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
if (viewOptions.showDetails) {
|
|
||||||
DelayedDetailsBackdropImage(
|
|
||||||
item = focusedItem,
|
|
||||||
modifier = Modifier,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
|
@ -786,12 +816,12 @@ fun CollectionFolderGridContent(
|
||||||
title = R.string.play,
|
title = R.string.play,
|
||||||
resume = Duration.ZERO,
|
resume = Duration.ZERO,
|
||||||
icon = Icons.Default.PlayArrow,
|
icon = Icons.Default.PlayArrow,
|
||||||
onClick = { onClickPlay.invoke(false) },
|
onClick = { onClickPlayAll.invoke(false) },
|
||||||
)
|
)
|
||||||
ExpandableFaButton(
|
ExpandableFaButton(
|
||||||
title = R.string.shuffle,
|
title = R.string.shuffle,
|
||||||
iconStringRes = R.string.fa_shuffle,
|
iconStringRes = R.string.fa_shuffle,
|
||||||
onClick = { onClickPlay.invoke(true) },
|
onClick = { onClickPlayAll.invoke(true) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -813,6 +843,7 @@ fun CollectionFolderGridContent(
|
||||||
pager = pager,
|
pager = pager,
|
||||||
onClickItem = onClickItem,
|
onClickItem = onClickItem,
|
||||||
onLongClickItem = onLongClickItem,
|
onLongClickItem = onLongClickItem,
|
||||||
|
onClickPlay = onClickPlay,
|
||||||
letterPosition = letterPosition,
|
letterPosition = letterPosition,
|
||||||
gridFocusRequester = gridFocusRequester,
|
gridFocusRequester = gridFocusRequester,
|
||||||
showJumpButtons = false, // TODO add preference
|
showJumpButtons = false, // TODO add preference
|
||||||
|
|
@ -832,6 +863,7 @@ fun CollectionFolderGridContent(
|
||||||
imageContentScale = viewOptions.contentScale.scale,
|
imageContentScale = viewOptions.contentScale.scale,
|
||||||
imageAspectRatio = viewOptions.aspectRatio.ratio,
|
imageAspectRatio = viewOptions.aspectRatio.ratio,
|
||||||
imageType = viewOptions.imageType,
|
imageType = viewOptions.imageType,
|
||||||
|
showTitle = viewOptions.showTitles,
|
||||||
modifier = mod,
|
modifier = mod,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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
|
@Composable
|
||||||
fun ScrollableDialog(
|
fun ScrollableDialog(
|
||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
|
width: Dp = 600.dp,
|
||||||
|
maxHeight: Dp = 380.dp,
|
||||||
|
itemSpacing: Dp = 8.dp,
|
||||||
content: LazyListScope.() -> Unit,
|
content: LazyListScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
val scrollAmount = 100f
|
val scrollAmount = 100f
|
||||||
|
|
@ -331,12 +334,12 @@ fun ScrollableDialog(
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = columnState,
|
state = columnState,
|
||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(itemSpacing),
|
||||||
content = content,
|
content = content,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.width(600.dp)
|
.width(width)
|
||||||
.heightIn(max = 380.dp)
|
.heightIn(max = maxHeight)
|
||||||
.focusable()
|
.focusable()
|
||||||
.background(
|
.background(
|
||||||
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
|
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
|
||||||
|
|
|
||||||
|
|
@ -5,33 +5,43 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.defaultMinSize
|
import androidx.compose.foundation.layout.defaultMinSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.BasicSecureTextField
|
import androidx.compose.foundation.text.BasicSecureTextField
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.text.input.KeyboardActionHandler
|
import androidx.compose.foundation.text.input.KeyboardActionHandler
|
||||||
import androidx.compose.foundation.text.input.TextFieldDecorator
|
import androidx.compose.foundation.text.input.TextFieldDecorator
|
||||||
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||||
import androidx.compose.foundation.text.input.TextFieldState
|
import androidx.compose.foundation.text.input.TextFieldState
|
||||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||||
|
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.TextFieldDefaults
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
|
import androidx.compose.material3.TextFieldDefaults.Container
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.material3.Icon
|
import androidx.tv.material3.Icon
|
||||||
import androidx.tv.material3.LocalContentColor
|
import androidx.tv.material3.LocalContentColor
|
||||||
|
|
@ -39,6 +49,7 @@ import androidx.tv.material3.MaterialTheme
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
|
import com.google.protobuf.value
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -191,6 +202,159 @@ fun SearchEditTextBox(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A modified [BasicTextField] that looks & fits better with TV material controls
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun EditTextBox(
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||||
|
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
||||||
|
leadingIcon: @Composable (() -> Unit)? = null,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
readOnly: Boolean = false,
|
||||||
|
height: Dp = 40.dp,
|
||||||
|
isInputValid: (String) -> Boolean = { true },
|
||||||
|
supportingText: @Composable (() -> Unit)? = null,
|
||||||
|
placeholder: @Composable (() -> Unit)? = null,
|
||||||
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
) {
|
||||||
|
val colors =
|
||||||
|
TextFieldDefaults.colors(
|
||||||
|
unfocusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
|
||||||
|
focusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f),
|
||||||
|
disabledTextColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.25f),
|
||||||
|
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.25f),
|
||||||
|
errorContainerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.75f),
|
||||||
|
errorTextColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
)
|
||||||
|
CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) {
|
||||||
|
BasicTextField(
|
||||||
|
value = value,
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.defaultMinSize(
|
||||||
|
minWidth = TextFieldDefaults.MinWidth,
|
||||||
|
minHeight = height,
|
||||||
|
).height(height),
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
enabled = enabled,
|
||||||
|
readOnly = readOnly,
|
||||||
|
textStyle = MaterialTheme.typography.bodyLarge.merge(MaterialTheme.colorScheme.onPrimaryContainer),
|
||||||
|
cursorBrush = SolidColor(colors.cursorColor),
|
||||||
|
keyboardOptions = keyboardOptions,
|
||||||
|
keyboardActions = keyboardActions,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
singleLine = true,
|
||||||
|
maxLines = 1,
|
||||||
|
minLines = 1,
|
||||||
|
visualTransformation =
|
||||||
|
if (keyboardOptions.keyboardType == KeyboardType.Password ||
|
||||||
|
keyboardOptions.keyboardType == KeyboardType.NumberPassword
|
||||||
|
) {
|
||||||
|
PasswordVisualTransformation()
|
||||||
|
} else {
|
||||||
|
VisualTransformation.None
|
||||||
|
},
|
||||||
|
decorationBox =
|
||||||
|
@Composable { innerTextField ->
|
||||||
|
// places leading icon, text field with label and placeholder, trailing icon
|
||||||
|
TextFieldDefaults.DecorationBox(
|
||||||
|
value = value,
|
||||||
|
visualTransformation =
|
||||||
|
if (keyboardOptions.keyboardType == KeyboardType.Password ||
|
||||||
|
keyboardOptions.keyboardType == KeyboardType.NumberPassword
|
||||||
|
) {
|
||||||
|
PasswordVisualTransformation()
|
||||||
|
} else {
|
||||||
|
VisualTransformation.None
|
||||||
|
},
|
||||||
|
innerTextField = innerTextField,
|
||||||
|
placeholder = placeholder,
|
||||||
|
label = null,
|
||||||
|
leadingIcon = leadingIcon,
|
||||||
|
trailingIcon = null,
|
||||||
|
prefix = null,
|
||||||
|
suffix = null,
|
||||||
|
supportingText = supportingText,
|
||||||
|
shape = CircleShape,
|
||||||
|
singleLine = true,
|
||||||
|
enabled = enabled,
|
||||||
|
isError = false,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
colors = colors,
|
||||||
|
contentPadding =
|
||||||
|
PaddingValues(
|
||||||
|
horizontal = 8.dp,
|
||||||
|
vertical = 10.dp,
|
||||||
|
),
|
||||||
|
container = {
|
||||||
|
Container(
|
||||||
|
enabled = enabled,
|
||||||
|
isError = !isInputValid.invoke(value),
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
modifier = Modifier,
|
||||||
|
colors = colors,
|
||||||
|
shape = CircleShape,
|
||||||
|
focusedIndicatorLineThickness = 4.dp,
|
||||||
|
unfocusedIndicatorLineThickness = 0.dp,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* And [EditTextBox] styles for searches
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SearchEditTextBox(
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
onSearchClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
readOnly: Boolean = false,
|
||||||
|
height: Dp = 40.dp,
|
||||||
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
) {
|
||||||
|
EditTextBox(
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
modifier,
|
||||||
|
keyboardOptions =
|
||||||
|
KeyboardOptions(
|
||||||
|
autoCorrectEnabled = false,
|
||||||
|
imeAction = ImeAction.Search,
|
||||||
|
),
|
||||||
|
keyboardActions =
|
||||||
|
KeyboardActions(
|
||||||
|
onSearch = {
|
||||||
|
onSearchClick.invoke()
|
||||||
|
this.defaultKeyboardAction(ImeAction.Search)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Search,
|
||||||
|
contentDescription = stringResource(R.string.search),
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = enabled,
|
||||||
|
readOnly = readOnly,
|
||||||
|
height = height,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@PreviewTvSpec
|
@PreviewTvSpec
|
||||||
@Composable
|
@Composable
|
||||||
private fun EditTextBoxPreview() {
|
private fun EditTextBoxPreview() {
|
||||||
|
|
@ -199,6 +363,10 @@ private fun EditTextBoxPreview() {
|
||||||
EditTextBox(
|
EditTextBox(
|
||||||
state = rememberTextFieldState("string"),
|
state = rememberTextFieldState("string"),
|
||||||
)
|
)
|
||||||
|
EditTextBox(
|
||||||
|
value = "string",
|
||||||
|
onValueChange = {},
|
||||||
|
)
|
||||||
SearchEditTextBox(
|
SearchEditTextBox(
|
||||||
state = rememberTextFieldState("search query"),
|
state = rememberTextFieldState("search query"),
|
||||||
onSearchClick = { },
|
onSearchClick = { },
|
||||||
|
|
|
||||||
|
|
@ -10,30 +10,43 @@ import androidx.compose.runtime.livedata.observeAsState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
|
||||||
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||||
|
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
||||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||||
import com.github.damontecres.wholphin.ui.cards.GridCard
|
import com.github.damontecres.wholphin.ui.cards.GenreCard
|
||||||
import com.github.damontecres.wholphin.ui.detail.CardGrid
|
import com.github.damontecres.wholphin.ui.detail.CardGrid
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.CardGridItem
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
|
||||||
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
|
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
|
||||||
|
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
|
import kotlinx.coroutines.sync.withPermit
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
import org.jellyfin.sdk.model.api.ItemFields
|
||||||
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
import org.jellyfin.sdk.model.api.request.GetGenresRequest
|
import org.jellyfin.sdk.model.api.request.GetGenresRequest
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
|
|
@ -41,11 +54,12 @@ class GenreViewModel
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
private val api: ApiClient,
|
private val api: ApiClient,
|
||||||
|
private val imageUrlService: ImageUrlService,
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private lateinit var itemId: UUID
|
private lateinit var itemId: UUID
|
||||||
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||||
val genres = MutableLiveData<List<BaseItem?>>(listOf())
|
val genres = MutableLiveData<List<Genre>>(listOf())
|
||||||
|
|
||||||
fun init(itemId: UUID) {
|
fun init(itemId: UUID) {
|
||||||
loading.value = LoadingState.Loading
|
loading.value = LoadingState.Loading
|
||||||
|
|
@ -56,11 +70,69 @@ class GenreViewModel
|
||||||
parentId = itemId,
|
parentId = itemId,
|
||||||
fields = SlimItemFields,
|
fields = SlimItemFields,
|
||||||
)
|
)
|
||||||
val pager = ApiRequestPager(api, request, GetGenresRequestHandler, viewModelScope).init()
|
val genres =
|
||||||
|
GetGenresRequestHandler
|
||||||
|
.execute(api, request)
|
||||||
|
.content.items
|
||||||
|
.map {
|
||||||
|
Genre(it.id, it.name ?: "", null, Color.Black)
|
||||||
|
}
|
||||||
|
// val pager = ApiRequestPager(api, request, GetGenresRequestHandler, viewModelScope).init()
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
genres.value = pager
|
this@GenreViewModel.genres.value = genres
|
||||||
loading.value = LoadingState.Success
|
loading.value = LoadingState.Success
|
||||||
}
|
}
|
||||||
|
// val excludeItemIds = mutableSetOf<UUID>()
|
||||||
|
val genreToUrl = ConcurrentHashMap<UUID, String>()
|
||||||
|
val semaphore = Semaphore(4)
|
||||||
|
genres
|
||||||
|
.map { genre ->
|
||||||
|
viewModelScope.async(Dispatchers.IO) {
|
||||||
|
semaphore.withPermit {
|
||||||
|
val item =
|
||||||
|
GetItemsRequestHandler
|
||||||
|
.execute(
|
||||||
|
api,
|
||||||
|
GetItemsRequest(
|
||||||
|
// excludeItemIds = excludeItemIds,
|
||||||
|
parentId = itemId,
|
||||||
|
recursive = true,
|
||||||
|
limit = 1,
|
||||||
|
sortBy = listOf(ItemSortBy.RANDOM),
|
||||||
|
fields = listOf(ItemFields.GENRES),
|
||||||
|
imageTypes = listOf(ImageType.THUMB),
|
||||||
|
imageTypeLimit = 1,
|
||||||
|
includeItemTypes =
|
||||||
|
listOf(
|
||||||
|
BaseItemKind.MOVIE,
|
||||||
|
BaseItemKind.SERIES,
|
||||||
|
),
|
||||||
|
genreIds = listOf(genre.id),
|
||||||
|
enableTotalRecordCount = false,
|
||||||
|
),
|
||||||
|
).content.items
|
||||||
|
.firstOrNull()
|
||||||
|
if (item != null) {
|
||||||
|
// excludeItemIds.add(item.id)
|
||||||
|
genreToUrl[genre.id] =
|
||||||
|
imageUrlService.getItemImageUrl(
|
||||||
|
item.id,
|
||||||
|
item.type,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
ImageType.THUMB,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.awaitAll()
|
||||||
|
val genresWithImages =
|
||||||
|
genres.map {
|
||||||
|
it.copy(
|
||||||
|
imageUrl = genreToUrl[it.id],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
this@GenreViewModel.genres.setValueOnMain(genresWithImages)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,6 +150,16 @@ class GenreViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class Genre(
|
||||||
|
override val id: UUID,
|
||||||
|
val name: String,
|
||||||
|
val imageUrl: String?,
|
||||||
|
val color: Color,
|
||||||
|
) : CardGridItem {
|
||||||
|
override val playable: Boolean = false
|
||||||
|
override val sortName: String get() = name
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun GenreCardGrid(
|
fun GenreCardGrid(
|
||||||
itemId: UUID,
|
itemId: UUID,
|
||||||
|
|
@ -117,6 +199,7 @@ fun GenreCardGrid(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onLongClickItem = { _, _ -> },
|
onLongClickItem = { _, _ -> },
|
||||||
|
onClickPlay = { _, _ -> },
|
||||||
letterPosition = { viewModel.positionOfLetter(it) },
|
letterPosition = { viewModel.positionOfLetter(it) },
|
||||||
gridFocusRequester = gridFocusRequester,
|
gridFocusRequester = gridFocusRequester,
|
||||||
showJumpButtons = false,
|
showJumpButtons = false,
|
||||||
|
|
@ -125,13 +208,13 @@ fun GenreCardGrid(
|
||||||
initialPosition = 0,
|
initialPosition = 0,
|
||||||
positionCallback = { columns, position ->
|
positionCallback = { columns, position ->
|
||||||
},
|
},
|
||||||
cardContent = { item: BaseItem?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier ->
|
columns = 4,
|
||||||
GridCard(
|
cardContent = { item: Genre?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier ->
|
||||||
item = item,
|
GenreCard(
|
||||||
|
genre = item,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
modifier = mod,
|
modifier = mod,
|
||||||
imageAspectRatio = 1f,
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,7 @@ fun ItemGrid(
|
||||||
viewModel.navigateTo(Destination.Playback(item.id, 0))
|
viewModel.navigateTo(Destination.Playback(item.id, 0))
|
||||||
},
|
},
|
||||||
onLongClickItem = { index: Int, item: BaseItem -> },
|
onLongClickItem = { index: Int, item: BaseItem -> },
|
||||||
|
onClickPlay = { _, item -> viewModel.navigateTo(Destination.Playback(item)) },
|
||||||
letterPosition = { c: Char -> 0 },
|
letterPosition = { c: Char -> 0 },
|
||||||
gridFocusRequester = focusRequester,
|
gridFocusRequester = focusRequester,
|
||||||
showJumpButtons = false,
|
showJumpButtons = false,
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,32 @@
|
||||||
package com.github.damontecres.wholphin.ui.components
|
package com.github.damontecres.wholphin.ui.components
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.ui.TimeFormatter
|
||||||
import com.github.damontecres.wholphin.ui.roundMinutes
|
import com.github.damontecres.wholphin.ui.roundMinutes
|
||||||
import com.github.damontecres.wholphin.ui.timeRemaining
|
import com.github.damontecres.wholphin.ui.timeRemaining
|
||||||
|
import com.github.damontecres.wholphin.ui.util.LocalClock
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MovieQuickDetails(
|
fun MovieQuickDetails(
|
||||||
dto: BaseItemDto?,
|
dto: BaseItemDto?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val now = LocalClock.current.now
|
||||||
val details =
|
val details =
|
||||||
remember(dto) {
|
remember(dto, now) {
|
||||||
buildList {
|
buildList {
|
||||||
dto?.productionYear?.let { add(it.toString()) }
|
dto?.productionYear?.let { add(it.toString()) }
|
||||||
dto?.runTimeTicks?.ticks?.roundMinutes?.let {
|
addRuntimeDetails(context, now, dto)
|
||||||
add(it.toString())
|
|
||||||
}
|
|
||||||
dto?.timeRemaining?.roundMinutes?.let {
|
|
||||||
add("$it left")
|
|
||||||
}
|
|
||||||
dto?.officialRating?.let(::add)
|
dto?.officialRating?.let(::add)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -36,3 +39,21 @@ fun MovieQuickDetails(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun MutableList<String>.addRuntimeDetails(
|
||||||
|
context: Context,
|
||||||
|
now: LocalDateTime,
|
||||||
|
dto: BaseItemDto?,
|
||||||
|
) {
|
||||||
|
val runtime = dto?.runTimeTicks?.ticks
|
||||||
|
runtime?.let { duration ->
|
||||||
|
add(duration.roundMinutes.toString())
|
||||||
|
}
|
||||||
|
dto?.timeRemaining?.roundMinutes?.let {
|
||||||
|
add("$it left")
|
||||||
|
}
|
||||||
|
(dto?.timeRemaining ?: runtime)?.let { remaining ->
|
||||||
|
val endTimeStr = TimeFormatter.format(now.plusSeconds(remaining.inWholeSeconds))
|
||||||
|
add(context.getString(R.string.ends_at, endTimeStr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,39 +78,37 @@ fun ExpandablePlayButtons(
|
||||||
if (resumePosition > Duration.ZERO) {
|
if (resumePosition > Duration.ZERO) {
|
||||||
item("play") {
|
item("play") {
|
||||||
ExpandablePlayButton(
|
ExpandablePlayButton(
|
||||||
R.string.resume,
|
title = R.string.resume,
|
||||||
resumePosition,
|
resume = resumePosition,
|
||||||
Icons.Default.PlayArrow,
|
icon = Icons.Default.PlayArrow,
|
||||||
playOnClick,
|
onClick = playOnClick,
|
||||||
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.onFocusChanged(buttonOnFocusChanged)
|
.onFocusChanged(buttonOnFocusChanged)
|
||||||
.focusRequester(firstFocus)
|
.focusRequester(firstFocus),
|
||||||
.animateItem(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item("restart") {
|
item("restart") {
|
||||||
ExpandablePlayButton(
|
ExpandablePlayButton(
|
||||||
R.string.restart,
|
title = R.string.restart,
|
||||||
Duration.ZERO,
|
resume = Duration.ZERO,
|
||||||
Icons.Default.Refresh,
|
icon = Icons.Default.Refresh,
|
||||||
playOnClick,
|
onClick = playOnClick,
|
||||||
Modifier
|
modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
|
||||||
.onFocusChanged(buttonOnFocusChanged)
|
|
||||||
.animateItem(),
|
|
||||||
mirrorIcon = true,
|
mirrorIcon = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
item("play") {
|
item("play") {
|
||||||
ExpandablePlayButton(
|
ExpandablePlayButton(
|
||||||
R.string.play,
|
title = R.string.play,
|
||||||
Duration.ZERO,
|
resume = Duration.ZERO,
|
||||||
Icons.Default.PlayArrow,
|
icon = Icons.Default.PlayArrow,
|
||||||
playOnClick,
|
onClick = playOnClick,
|
||||||
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.onFocusChanged(buttonOnFocusChanged)
|
.onFocusChanged(buttonOnFocusChanged)
|
||||||
.focusRequester(firstFocus)
|
.focusRequester(firstFocus),
|
||||||
.animateItem(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -121,10 +119,7 @@ fun ExpandablePlayButtons(
|
||||||
title = if (watched) R.string.mark_unwatched else R.string.mark_watched,
|
title = if (watched) R.string.mark_unwatched else R.string.mark_watched,
|
||||||
iconStringRes = if (watched) R.string.fa_eye else R.string.fa_eye_slash,
|
iconStringRes = if (watched) R.string.fa_eye else R.string.fa_eye_slash,
|
||||||
onClick = watchOnClick,
|
onClick = watchOnClick,
|
||||||
modifier =
|
modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
|
||||||
Modifier
|
|
||||||
.onFocusChanged(buttonOnFocusChanged)
|
|
||||||
.animateItem(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -135,23 +130,18 @@ fun ExpandablePlayButtons(
|
||||||
iconStringRes = R.string.fa_heart,
|
iconStringRes = R.string.fa_heart,
|
||||||
onClick = favoriteOnClick,
|
onClick = favoriteOnClick,
|
||||||
iconColor = if (favorite) Color.Red else Color.Unspecified,
|
iconColor = if (favorite) Color.Red else Color.Unspecified,
|
||||||
modifier =
|
modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
|
||||||
Modifier
|
|
||||||
.onFocusChanged(buttonOnFocusChanged)
|
|
||||||
.animateItem(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// More button
|
// More button
|
||||||
item("more") {
|
item("more") {
|
||||||
ExpandablePlayButton(
|
ExpandablePlayButton(
|
||||||
R.string.more,
|
title = R.string.more,
|
||||||
Duration.ZERO,
|
resume = Duration.ZERO,
|
||||||
Icons.Default.MoreVert,
|
icon = Icons.Default.MoreVert,
|
||||||
{ moreOnClick.invoke() },
|
onClick = { moreOnClick.invoke() },
|
||||||
Modifier
|
modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
|
||||||
.onFocusChanged(buttonOnFocusChanged)
|
|
||||||
.animateItem(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import androidx.lifecycle.viewModelScope
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
||||||
|
|
@ -31,6 +32,7 @@ import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.main.HomePageContent
|
import com.github.damontecres.wholphin.ui.main.HomePageContent
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
|
|
@ -44,6 +46,7 @@ abstract class RecommendedViewModel(
|
||||||
val context: Context,
|
val context: Context,
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
val favoriteWatchManager: FavoriteWatchManager,
|
val favoriteWatchManager: FavoriteWatchManager,
|
||||||
|
private val backdropService: BackdropService,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
abstract fun init()
|
abstract fun init()
|
||||||
|
|
||||||
|
|
@ -85,6 +88,12 @@ abstract class RecommendedViewModel(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun updateBackdrop(item: BaseItem) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
backdropService.submit(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
abstract fun update(
|
abstract fun update(
|
||||||
@StringRes title: Int,
|
@StringRes title: Int,
|
||||||
row: HomeRowLoadingState,
|
row: HomeRowLoadingState,
|
||||||
|
|
@ -146,8 +155,12 @@ fun RecommendedContent(
|
||||||
onLongClickItem = { position, item ->
|
onLongClickItem = { position, item ->
|
||||||
moreDialog.makePresent(RowColumnItem(position, item))
|
moreDialog.makePresent(RowColumnItem(position, item))
|
||||||
},
|
},
|
||||||
|
onClickPlay = { _, item ->
|
||||||
|
viewModel.navigationManager.navigateTo(Destination.Playback(item))
|
||||||
|
},
|
||||||
onFocusPosition = onFocusPosition,
|
onFocusPosition = onFocusPosition,
|
||||||
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
||||||
|
onUpdateBackdrop = viewModel::updateBackdrop,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||||
|
|
@ -56,7 +57,8 @@ class RecommendedMovieViewModel
|
||||||
@Assisted val parentId: UUID,
|
@Assisted val parentId: UUID,
|
||||||
navigationManager: NavigationManager,
|
navigationManager: NavigationManager,
|
||||||
favoriteWatchManager: FavoriteWatchManager,
|
favoriteWatchManager: FavoriteWatchManager,
|
||||||
) : RecommendedViewModel(context, navigationManager, favoriteWatchManager) {
|
backdropService: BackdropService,
|
||||||
|
) : RecommendedViewModel(context, navigationManager, favoriteWatchManager, backdropService) {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
interface Factory {
|
interface Factory {
|
||||||
fun create(parentId: UUID): RecommendedMovieViewModel
|
fun create(parentId: UUID): RecommendedMovieViewModel
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||||
|
|
@ -59,7 +60,8 @@ class RecommendedTvShowViewModel
|
||||||
@Assisted val parentId: UUID,
|
@Assisted val parentId: UUID,
|
||||||
navigationManager: NavigationManager,
|
navigationManager: NavigationManager,
|
||||||
favoriteWatchManager: FavoriteWatchManager,
|
favoriteWatchManager: FavoriteWatchManager,
|
||||||
) : RecommendedViewModel(context, navigationManager, favoriteWatchManager) {
|
backdropService: BackdropService,
|
||||||
|
) : RecommendedViewModel(context, navigationManager, favoriteWatchManager, backdropService) {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
interface Factory {
|
interface Factory {
|
||||||
fun create(parentId: UUID): RecommendedTvShowViewModel
|
fun create(parentId: UUID): RecommendedTvShowViewModel
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ package com.github.damontecres.wholphin.ui.components
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
|
|
@ -10,7 +12,7 @@ import com.github.damontecres.wholphin.ui.formatDateTime
|
||||||
import com.github.damontecres.wholphin.ui.roundMinutes
|
import com.github.damontecres.wholphin.ui.roundMinutes
|
||||||
import com.github.damontecres.wholphin.ui.seasonEpisode
|
import com.github.damontecres.wholphin.ui.seasonEpisode
|
||||||
import com.github.damontecres.wholphin.ui.seriesProductionYears
|
import com.github.damontecres.wholphin.ui.seriesProductionYears
|
||||||
import com.github.damontecres.wholphin.ui.timeRemaining
|
import com.github.damontecres.wholphin.ui.util.LocalClock
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
|
|
||||||
|
|
@ -23,6 +25,7 @@ fun SeriesName(
|
||||||
text = seriesName ?: "",
|
text = seriesName ?: "",
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|
@ -55,17 +58,14 @@ fun EpisodeQuickDetails(
|
||||||
dto: BaseItemDto?,
|
dto: BaseItemDto?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val now = LocalClock.current.now
|
||||||
val details =
|
val details =
|
||||||
remember(dto) {
|
remember(dto, now) {
|
||||||
buildList {
|
buildList {
|
||||||
dto?.seasonEpisode?.let(::add)
|
dto?.seasonEpisode?.let(::add)
|
||||||
dto?.premiereDate?.let { add(formatDateTime(it)) }
|
dto?.premiereDate?.let { add(formatDateTime(it)) }
|
||||||
val duration = dto?.runTimeTicks?.ticks
|
addRuntimeDetails(context, now, dto)
|
||||||
duration
|
|
||||||
?.roundMinutes
|
|
||||||
?.toString()
|
|
||||||
?.let(::add)
|
|
||||||
dto?.timeRemaining?.roundMinutes?.let { add("$it left") }
|
|
||||||
dto?.officialRating?.let(::add)
|
dto?.officialRating?.let(::add)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -87,11 +87,9 @@ fun SeriesQuickDetails(
|
||||||
remember(dto) {
|
remember(dto) {
|
||||||
buildList {
|
buildList {
|
||||||
dto?.seriesProductionYears?.let(::add)
|
dto?.seriesProductionYears?.let(::add)
|
||||||
val duration = dto?.runTimeTicks?.ticks
|
dto?.runTimeTicks?.ticks?.roundMinutes?.let {
|
||||||
duration
|
add(it.toString())
|
||||||
?.roundMinutes
|
}
|
||||||
?.toString()
|
|
||||||
?.let(::add)
|
|
||||||
dto?.officialRating?.let(::add)
|
dto?.officialRating?.let(::add)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,35 +3,21 @@ package com.github.damontecres.wholphin.ui.components
|
||||||
import androidx.compose.foundation.layout.BoxScope
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.ui.TimeFormatter
|
import com.github.damontecres.wholphin.ui.util.LocalClock
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import java.time.LocalTime
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun BoxScope.TimeDisplay(modifier: Modifier = Modifier) {
|
fun BoxScope.TimeDisplay(modifier: Modifier = Modifier) {
|
||||||
var now by remember { mutableStateOf(LocalTime.now()) }
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
while (isActive) {
|
|
||||||
now = LocalTime.now()
|
|
||||||
delay(1000L)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(
|
Text(
|
||||||
text = TimeFormatter.format(now),
|
text = LocalClock.current.timeString,
|
||||||
fontSize = 18.sp,
|
fontSize = 18.sp,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
modifier =
|
modifier =
|
||||||
modifier
|
modifier
|
||||||
.align(Alignment.TopEnd)
|
.align(Alignment.TopEnd)
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,11 @@ import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.NonRestartableComposable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
@ -23,30 +27,41 @@ import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.ProvideTextStyle
|
import androidx.tv.material3.ProvideTextStyle
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
import com.github.damontecres.wholphin.data.ChosenStreams
|
||||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
|
||||||
import com.github.damontecres.wholphin.data.model.chooseSource
|
|
||||||
import com.github.damontecres.wholphin.data.model.chooseStream
|
|
||||||
import com.github.damontecres.wholphin.preferences.AppThemeColors
|
import com.github.damontecres.wholphin.preferences.AppThemeColors
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
|
||||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||||
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.playback.audioStreamCount
|
import com.github.damontecres.wholphin.ui.playback.audioStreamCount
|
||||||
import com.github.damontecres.wholphin.ui.playback.embeddedSubtitleCount
|
import com.github.damontecres.wholphin.ui.playback.embeddedSubtitleCount
|
||||||
import com.github.damontecres.wholphin.ui.playback.externalSubtitlesCount
|
import com.github.damontecres.wholphin.ui.playback.externalSubtitlesCount
|
||||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
import com.github.damontecres.wholphin.util.languageName
|
import com.github.damontecres.wholphin.util.languageName
|
||||||
import com.github.damontecres.wholphin.util.profile.Codec
|
import com.github.damontecres.wholphin.util.profile.Codec
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
import org.jellyfin.sdk.model.api.MediaStream
|
||||||
import org.jellyfin.sdk.model.api.VideoRange
|
import org.jellyfin.sdk.model.api.VideoRange
|
||||||
import org.jellyfin.sdk.model.api.VideoRangeType
|
import org.jellyfin.sdk.model.api.VideoRangeType
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
@NonRestartableComposable
|
||||||
fun VideoStreamDetails(
|
fun VideoStreamDetails(
|
||||||
preferences: UserPreferences,
|
chosenStreams: ChosenStreams?,
|
||||||
dto: BaseItemDto,
|
modifier: Modifier = Modifier,
|
||||||
itemPlayback: ItemPlayback?,
|
) = VideoStreamDetails(
|
||||||
|
chosenStreams?.source,
|
||||||
|
chosenStreams?.videoStream,
|
||||||
|
chosenStreams?.audioStream,
|
||||||
|
chosenStreams?.subtitleStream,
|
||||||
|
modifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun VideoStreamDetails(
|
||||||
|
source: MediaSourceInfo?,
|
||||||
|
videoStream: MediaStream?,
|
||||||
|
audioStream: MediaStream?,
|
||||||
|
subtitleStream: MediaStream?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
@ -54,17 +69,6 @@ fun VideoStreamDetails(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
val source = remember(dto, itemPlayback) { chooseSource(dto, itemPlayback) }
|
|
||||||
|
|
||||||
val videoStream =
|
|
||||||
remember(dto, itemPlayback) {
|
|
||||||
chooseStream(
|
|
||||||
dto,
|
|
||||||
itemPlayback,
|
|
||||||
MediaStreamType.VIDEO,
|
|
||||||
preferences,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val video =
|
val video =
|
||||||
remember(videoStream) {
|
remember(videoStream) {
|
||||||
videoStream
|
videoStream
|
||||||
|
|
@ -81,7 +85,7 @@ fun VideoStreamDetails(
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
val range = formatVideoRange(context, it.videoRange, it.videoRangeType)
|
val range = formatVideoRange(context, it.videoRange, it.videoRangeType, it.videoDoViTitle)
|
||||||
listOfNotNull(
|
listOfNotNull(
|
||||||
resName.concatWithSpace(range),
|
resName.concatWithSpace(range),
|
||||||
it.codec?.uppercase(),
|
it.codec?.uppercase(),
|
||||||
|
|
@ -92,19 +96,11 @@ fun VideoStreamDetails(
|
||||||
StreamLabel(it)
|
StreamLabel(it)
|
||||||
}
|
}
|
||||||
|
|
||||||
val audioStream =
|
|
||||||
remember(dto, itemPlayback) {
|
|
||||||
chooseStream(
|
|
||||||
dto,
|
|
||||||
itemPlayback,
|
|
||||||
MediaStreamType.AUDIO,
|
|
||||||
preferences,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val audioCount = remember(source) { source?.audioStreamCount ?: 0 }
|
val audioCount = remember(source) { source?.audioStreamCount ?: 0 }
|
||||||
val audio =
|
val audio =
|
||||||
|
remember(audioCount, audioStream) {
|
||||||
if (audioCount == 0 || audioStream == null) {
|
if (audioCount == 0 || audioStream == null) {
|
||||||
stringResource(R.string.none)
|
context.getString(R.string.none)
|
||||||
} else {
|
} else {
|
||||||
listOfNotNull(
|
listOfNotNull(
|
||||||
languageName(audioStream.language),
|
languageName(audioStream.language),
|
||||||
|
|
@ -112,6 +108,7 @@ fun VideoStreamDetails(
|
||||||
audioStream.channelLayout,
|
audioStream.channelLayout,
|
||||||
).joinToString(" ")
|
).joinToString(" ")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
StreamLabel(
|
StreamLabel(
|
||||||
text = audio,
|
text = audio,
|
||||||
count = audioCount,
|
count = audioCount,
|
||||||
|
|
@ -119,37 +116,34 @@ fun VideoStreamDetails(
|
||||||
modifier = Modifier.widthIn(max = 200.dp),
|
modifier = Modifier.widthIn(max = 200.dp),
|
||||||
)
|
)
|
||||||
|
|
||||||
val subtitleStream =
|
|
||||||
remember(dto, itemPlayback) {
|
|
||||||
chooseStream(
|
|
||||||
dto,
|
|
||||||
itemPlayback,
|
|
||||||
MediaStreamType.SUBTITLE,
|
|
||||||
preferences,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val subtitleCount =
|
val subtitleCount =
|
||||||
remember(source) {
|
remember(source) {
|
||||||
(source?.embeddedSubtitleCount ?: 0) + (source?.externalSubtitlesCount ?: 0)
|
(source?.embeddedSubtitleCount ?: 0) + (source?.externalSubtitlesCount ?: 0)
|
||||||
}
|
}
|
||||||
|
var disabled by remember { mutableStateOf(false) }
|
||||||
val subtitle =
|
val subtitle =
|
||||||
if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) {
|
remember(subtitleCount, subtitleStream) {
|
||||||
stringResource(R.string.disabled)
|
if (subtitleCount > 0 && subtitleStream == null) {
|
||||||
|
disabled = true
|
||||||
|
context.getString(R.string.disabled)
|
||||||
} else if (subtitleCount == 0 || subtitleStream == null) {
|
} else if (subtitleCount == 0 || subtitleStream == null) {
|
||||||
null
|
null
|
||||||
} else {
|
} else {
|
||||||
|
disabled = false
|
||||||
listOfNotNull(
|
listOfNotNull(
|
||||||
languageName(subtitleStream.language),
|
languageName(subtitleStream.language),
|
||||||
"SDH".takeIf { subtitleStream.isHearingImpaired },
|
"SDH".takeIf { subtitleStream.isHearingImpaired },
|
||||||
formatSubtitleCodec(subtitleStream.codec),
|
formatSubtitleCodec(subtitleStream.codec),
|
||||||
).joinToString(" ")
|
).joinToString(" ")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
subtitle?.let {
|
subtitle?.let {
|
||||||
StreamLabel(
|
StreamLabel(
|
||||||
text = it,
|
text = it,
|
||||||
count = subtitleCount,
|
count = subtitleCount,
|
||||||
icon = R.string.fa_closed_captioning,
|
icon = R.string.fa_closed_captioning,
|
||||||
modifier = Modifier.widthIn(max = 160.dp),
|
modifier = Modifier.widthIn(max = 160.dp),
|
||||||
|
disabled = disabled,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -190,6 +184,7 @@ fun StreamLabel(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@StringRes icon: Int? = null,
|
@StringRes icon: Int? = null,
|
||||||
count: Int = 0,
|
count: Int = 0,
|
||||||
|
disabled: Boolean = false,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
|
@ -222,9 +217,10 @@ fun StreamLabel(
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
)
|
)
|
||||||
if (count > 1) {
|
val countToUse = if (disabled) count else count - 1
|
||||||
|
if (countToUse > 0) {
|
||||||
Text(
|
Text(
|
||||||
text = "(+${count - 1})",
|
text = "(+$countToUse)",
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
)
|
)
|
||||||
|
|
@ -248,6 +244,10 @@ private fun StreamLabelPreview() {
|
||||||
StreamLabel("HDR")
|
StreamLabel("HDR")
|
||||||
StreamLabel("H264")
|
StreamLabel("H264")
|
||||||
StreamLabel("AC3 5.1", icon = R.string.fa_volume_high, count = 2)
|
StreamLabel("AC3 5.1", icon = R.string.fa_volume_high, count = 2)
|
||||||
|
|
||||||
|
StreamLabel("PGS", count = 1)
|
||||||
|
StreamLabel("PGS", count = 1, disabled = true)
|
||||||
|
StreamLabel("PGS", count = 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -256,6 +256,7 @@ fun formatVideoRange(
|
||||||
context: Context,
|
context: Context,
|
||||||
videoRange: VideoRange?,
|
videoRange: VideoRange?,
|
||||||
type: VideoRangeType?,
|
type: VideoRangeType?,
|
||||||
|
doviTitle: String?,
|
||||||
): String? =
|
): String? =
|
||||||
when (videoRange) {
|
when (videoRange) {
|
||||||
VideoRange.UNKNOWN,
|
VideoRange.UNKNOWN,
|
||||||
|
|
@ -265,6 +266,9 @@ fun formatVideoRange(
|
||||||
}
|
}
|
||||||
|
|
||||||
VideoRange.HDR -> {
|
VideoRange.HDR -> {
|
||||||
|
if (doviTitle.isNotNullOrBlank()) {
|
||||||
|
context.getString(R.string.dolby_vision)
|
||||||
|
} else {
|
||||||
when (type) {
|
when (type) {
|
||||||
VideoRangeType.UNKNOWN,
|
VideoRangeType.UNKNOWN,
|
||||||
VideoRangeType.SDR,
|
VideoRangeType.SDR,
|
||||||
|
|
@ -285,6 +289,7 @@ fun formatVideoRange(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun formatAudioCodec(
|
fun formatAudioCodec(
|
||||||
context: Context,
|
context: Context,
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,7 @@ data class ViewOptions(
|
||||||
val aspectRatio: AspectRatio = AspectRatio.TALL,
|
val aspectRatio: AspectRatio = AspectRatio.TALL,
|
||||||
val showDetails: Boolean = false,
|
val showDetails: Boolean = false,
|
||||||
val imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
|
val imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
|
||||||
|
val showTitles: Boolean = true,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
val ViewOptionsColumns =
|
val ViewOptionsColumns =
|
||||||
|
|
@ -165,6 +166,13 @@ data class ViewOptions(
|
||||||
getter = { it.showDetails },
|
getter = { it.showDetails },
|
||||||
setter = { vo, value -> vo.copy(showDetails = value) },
|
setter = { vo, value -> vo.copy(showDetails = value) },
|
||||||
)
|
)
|
||||||
|
val ViewOptionsShowTitles =
|
||||||
|
AppSwitchPreference<ViewOptions>(
|
||||||
|
title = R.string.show_titles,
|
||||||
|
defaultValue = true,
|
||||||
|
getter = { it.showTitles },
|
||||||
|
setter = { vo, value -> vo.copy(showTitles = value) },
|
||||||
|
)
|
||||||
|
|
||||||
val ViewOptionsImageType =
|
val ViewOptionsImageType =
|
||||||
AppChoicePreference<ViewOptions, ViewOptionImageType>(
|
AppChoicePreference<ViewOptions, ViewOptionImageType>(
|
||||||
|
|
@ -194,6 +202,7 @@ data class ViewOptions(
|
||||||
ViewOptionsImageType,
|
ViewOptionsImageType,
|
||||||
ViewOptionsAspectRatio,
|
ViewOptionsAspectRatio,
|
||||||
ViewOptionsDetailHeader,
|
ViewOptionsDetailHeader,
|
||||||
|
ViewOptionsShowTitles,
|
||||||
ViewOptionsColumns,
|
ViewOptionsColumns,
|
||||||
ViewOptionsSpacing,
|
ViewOptionsSpacing,
|
||||||
ViewOptionsContentScale,
|
ViewOptionsContentScale,
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,35 @@
|
||||||
package com.github.damontecres.wholphin.ui.data
|
package com.github.damontecres.wholphin.ui.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.ProvideTextStyle
|
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.ui.byteRateSuffixes
|
import com.github.damontecres.wholphin.ui.byteRateSuffixes
|
||||||
import com.github.damontecres.wholphin.ui.components.ScrollableDialog
|
import com.github.damontecres.wholphin.ui.components.ScrollableDialog
|
||||||
|
import com.github.damontecres.wholphin.ui.components.formatAudioCodec
|
||||||
|
import com.github.damontecres.wholphin.ui.components.formatSubtitleCodec
|
||||||
import com.github.damontecres.wholphin.ui.formatBytes
|
import com.github.damontecres.wholphin.ui.formatBytes
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||||
import com.github.damontecres.wholphin.util.languageName
|
import com.github.damontecres.wholphin.util.languageName
|
||||||
import org.jellyfin.sdk.model.api.AudioSpatialFormat
|
|
||||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||||
|
import org.jellyfin.sdk.model.api.MediaStream
|
||||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
|
import org.jellyfin.sdk.model.api.VideoRange
|
||||||
|
import org.jellyfin.sdk.model.api.VideoRangeType
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
data class ItemDetailsDialogInfo(
|
data class ItemDetailsDialogInfo(
|
||||||
|
|
@ -37,8 +44,22 @@ fun ItemDetailsDialog(
|
||||||
info: ItemDetailsDialogInfo,
|
info: ItemDetailsDialogInfo,
|
||||||
showFilePath: Boolean,
|
showFilePath: Boolean,
|
||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
) = ScrollableDialog(
|
) {
|
||||||
|
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,
|
onDismissRequest = onDismissRequest,
|
||||||
|
width = 720.dp,
|
||||||
|
maxHeight = 480.dp,
|
||||||
|
itemSpacing = 4.dp,
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -62,104 +83,299 @@ fun ItemDetailsDialog(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (info.files.isNotEmpty()) {
|
|
||||||
|
// Show detailed media information for the selected source (first one if multiple)
|
||||||
|
info.files.forEachIndexed { index, source ->
|
||||||
|
source.mediaStreams?.letNotEmpty { mediaStreams ->
|
||||||
item {
|
item {
|
||||||
Spacer(Modifier.height(12.dp))
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
items(info.files) { file ->
|
|
||||||
ProvideTextStyle(MaterialTheme.typography.bodyMedium) {
|
|
||||||
MediaSourceInfo(file, showFilePath, Modifier.padding(top = 12.dp))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MediaSourceInfo(
|
private fun MediaInfoSection(
|
||||||
source: MediaSourceInfo,
|
title: String,
|
||||||
showFilePath: Boolean,
|
items: List<Pair<String, String>>,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
modifier = modifier,
|
modifier = modifier.padding(vertical = 4.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.name) + ": ${source.name}",
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
items.forEach { (label, value) ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(start = 12.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "$label: ",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "ID: ${source.id}",
|
text = value,
|
||||||
)
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
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 =
|
private fun buildVideoStreamInfo(
|
||||||
|
context: Context,
|
||||||
|
stream: MediaStream,
|
||||||
|
): List<Pair<String, String>> =
|
||||||
buildList {
|
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) {
|
if (stream.width != null && stream.height != null) {
|
||||||
add("${stream.width}x${stream.height}")
|
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 {
|
stream.averageFrameRate?.let {
|
||||||
add(String.format(Locale.getDefault(), "%.3f", it) + " fps")
|
add(framerateLabel to String.format(Locale.getDefault(), "%.3f", it))
|
||||||
}
|
}
|
||||||
stream.bitRate?.let { add(formatBytes(it, byteRateSuffixes)) }
|
stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) }
|
||||||
stream.codec?.let(::add)
|
stream.bitDepth?.let { add(bitDepthLabel to "$it $bitUnit") }
|
||||||
stream.profile?.let(::add)
|
stream.videoRange?.let {
|
||||||
|
val rangeStr =
|
||||||
|
when (it) {
|
||||||
|
VideoRange.SDR -> sdrStr
|
||||||
|
VideoRange.HDR -> hdrStr
|
||||||
|
VideoRange.UNKNOWN -> null
|
||||||
|
else -> null
|
||||||
}
|
}
|
||||||
Text(
|
rangeStr?.let { add(videoRangeLabel to it) }
|
||||||
text = stringResource(R.string.video) + ": " + data.joinToString(" - "),
|
}
|
||||||
)
|
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()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
streams.filter { it.type == MediaStreamType.AUDIO }.forEachIndexed { index, stream ->
|
private fun buildAudioStreamInfo(
|
||||||
val data =
|
context: Context,
|
||||||
|
stream: MediaStream,
|
||||||
|
): List<Pair<String, String>> =
|
||||||
buildList {
|
buildList {
|
||||||
stream.language?.let { add(languageName(it)) }
|
val titleLabel = context.getString(R.string.title)
|
||||||
stream.codec?.let(::add)
|
val languageLabel = context.getString(R.string.language)
|
||||||
stream.channelLayout?.let(::add)
|
val codecLabel = context.getString(R.string.codec)
|
||||||
stream.bitRate?.let { add(formatBytes(it, byteRateSuffixes)) }
|
val layoutLabel = context.getString(R.string.layout)
|
||||||
if (stream.audioSpatialFormat != AudioSpatialFormat.NONE) add(stream.audioSpatialFormat.serialName)
|
val channelsLabel = context.getString(R.string.channels)
|
||||||
if (stream.isDefault) add(stringResource(R.string.default_track))
|
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)
|
||||||
}
|
}
|
||||||
Text(
|
stream.channelLayout?.let { add(layoutLabel to it) }
|
||||||
text = stringResource(R.string.audio) + " ${index + 1}: " + data.joinToString(" - "),
|
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) }
|
||||||
}
|
}
|
||||||
|
|
||||||
streams.filter { it.type == MediaStreamType.SUBTITLE }.forEachIndexed { index, stream ->
|
private fun buildSubtitleStreamInfo(
|
||||||
val data =
|
context: Context,
|
||||||
|
stream: MediaStream,
|
||||||
|
): List<Pair<String, String>> =
|
||||||
buildList {
|
buildList {
|
||||||
stream.language?.let { add(languageName(it)) }
|
val titleLabel = context.getString(R.string.title)
|
||||||
stream.codec?.let(::add)
|
val languageLabel = context.getString(R.string.language)
|
||||||
if (stream.isDefault) add(stringResource(R.string.default_track))
|
val codecLabel = context.getString(R.string.codec)
|
||||||
if (stream.isForced) add(stringResource(R.string.forced_track))
|
val avcLabel = context.getString(R.string.avc)
|
||||||
if (stream.isExternal) add(stringResource(R.string.external_track))
|
val defaultLabel = context.getString(R.string.default_track)
|
||||||
}
|
val forcedLabel = context.getString(R.string.forced_track)
|
||||||
Text(
|
val externalLabel = context.getString(R.string.external_track)
|
||||||
text =
|
val yesStr = context.getString(R.string.yes)
|
||||||
stringResource(R.string.subtitle) + " ${index + 1}: " +
|
val noStr = context.getString(R.string.no)
|
||||||
data.joinToString(" - "),
|
|
||||||
)
|
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.grid.rememberLazyGridState
|
||||||
import androidx.compose.foundation.lazy.layout.LazyLayoutCacheWindow
|
import androidx.compose.foundation.lazy.layout.LazyLayoutCacheWindow
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
|
@ -33,11 +34,14 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusProperties
|
import androidx.compose.ui.focus.focusProperties
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.focusRestorer
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import androidx.compose.ui.input.key.KeyEventType
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
import androidx.compose.ui.input.key.key
|
import androidx.compose.ui.input.key.key
|
||||||
|
|
@ -49,6 +53,8 @@ import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.tv.material3.Button
|
||||||
|
import androidx.tv.material3.ButtonDefaults
|
||||||
import androidx.tv.material3.LocalContentColor
|
import androidx.tv.material3.LocalContentColor
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
|
|
@ -57,8 +63,6 @@ import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.ui.AppColors
|
import com.github.damontecres.wholphin.ui.AppColors
|
||||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||||
import com.github.damontecres.wholphin.ui.cards.GridCard
|
import com.github.damontecres.wholphin.ui.cards.GridCard
|
||||||
import com.github.damontecres.wholphin.ui.components.Button
|
|
||||||
import com.github.damontecres.wholphin.ui.components.TextButton
|
|
||||||
import com.github.damontecres.wholphin.ui.ifElse
|
import com.github.damontecres.wholphin.ui.ifElse
|
||||||
import com.github.damontecres.wholphin.ui.playback.isBackwardButton
|
import com.github.damontecres.wholphin.ui.playback.isBackwardButton
|
||||||
import com.github.damontecres.wholphin.ui.playback.isForwardButton
|
import com.github.damontecres.wholphin.ui.playback.isForwardButton
|
||||||
|
|
@ -69,15 +73,23 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
private const val DEBUG = false
|
private const val DEBUG = false
|
||||||
|
|
||||||
|
interface CardGridItem {
|
||||||
|
val id: UUID
|
||||||
|
val playable: Boolean
|
||||||
|
val sortName: String
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun CardGrid(
|
fun <T : CardGridItem> CardGrid(
|
||||||
pager: List<BaseItem?>,
|
pager: List<T?>,
|
||||||
onClickItem: (Int, BaseItem) -> Unit,
|
onClickItem: (Int, T) -> Unit,
|
||||||
onLongClickItem: (Int, BaseItem) -> Unit,
|
onLongClickItem: (Int, T) -> Unit,
|
||||||
|
onClickPlay: (Int, T) -> Unit,
|
||||||
letterPosition: suspend (Char) -> Int,
|
letterPosition: suspend (Char) -> Int,
|
||||||
gridFocusRequester: FocusRequester,
|
gridFocusRequester: FocusRequester,
|
||||||
showJumpButtons: Boolean,
|
showJumpButtons: Boolean,
|
||||||
|
|
@ -86,13 +98,13 @@ fun CardGrid(
|
||||||
initialPosition: Int = 0,
|
initialPosition: Int = 0,
|
||||||
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
||||||
cardContent: @Composable (
|
cardContent: @Composable (
|
||||||
item: BaseItem?,
|
item: T?,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
mod: Modifier,
|
mod: Modifier,
|
||||||
) -> Unit = { item, onClick, onLongClick, mod ->
|
) -> Unit = { item, onClick, onLongClick, mod ->
|
||||||
GridCard(
|
GridCard(
|
||||||
item = item,
|
item = item as BaseItem?,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
imageContentScale = ContentScale.FillBounds,
|
imageContentScale = ContentScale.FillBounds,
|
||||||
|
|
@ -213,7 +225,11 @@ fun CardGrid(
|
||||||
jumpToTop()
|
jumpToTop()
|
||||||
return@onKeyEvent true
|
return@onKeyEvent true
|
||||||
} else if (isPlayKeyUp(it)) {
|
} else if (isPlayKeyUp(it)) {
|
||||||
// TODO play the focused item
|
val item = pager.getOrNull(focusedIndex)
|
||||||
|
if (item?.playable == true) {
|
||||||
|
Timber.v("Clicked play on ${item.id}")
|
||||||
|
onClickPlay.invoke(focusedIndex, item)
|
||||||
|
}
|
||||||
return@onKeyEvent true
|
return@onKeyEvent true
|
||||||
} else if (useJumpRemoteButtons && isForwardButton(it)) {
|
} else if (useJumpRemoteButtons && isForwardButton(it)) {
|
||||||
jump(jump1)
|
jump(jump1)
|
||||||
|
|
@ -341,7 +357,6 @@ fun CardGrid(
|
||||||
remember(focusedIndex) {
|
remember(focusedIndex) {
|
||||||
pager
|
pager
|
||||||
.getOrNull(focusedIndex)
|
.getOrNull(focusedIndex)
|
||||||
?.data
|
|
||||||
?.sortName
|
?.sortName
|
||||||
?.first()
|
?.first()
|
||||||
?.uppercaseChar()
|
?.uppercaseChar()
|
||||||
|
|
@ -360,7 +375,11 @@ fun CardGrid(
|
||||||
AlphabetButtons(
|
AlphabetButtons(
|
||||||
letters = letters,
|
letters = letters,
|
||||||
currentLetter = currentLetter,
|
currentLetter = currentLetter,
|
||||||
modifier = Modifier.align(Alignment.CenterVertically),
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.CenterVertically)
|
||||||
|
.padding(end = 16.dp),
|
||||||
|
// Add end padding to push away from edge
|
||||||
letterClicked = { letter ->
|
letterClicked = { letter ->
|
||||||
scope.launch(ExceptionHandler()) {
|
scope.launch(ExceptionHandler()) {
|
||||||
val jumpPosition =
|
val jumpPosition =
|
||||||
|
|
@ -369,6 +388,7 @@ fun CardGrid(
|
||||||
}
|
}
|
||||||
Timber.d("Alphabet jump to $jumpPosition")
|
Timber.d("Alphabet jump to $jumpPosition")
|
||||||
if (jumpPosition >= 0) {
|
if (jumpPosition >= 0) {
|
||||||
|
pager.getOrNull(jumpPosition)
|
||||||
gridState.scrollToItem(jumpPosition)
|
gridState.scrollToItem(jumpPosition)
|
||||||
focusOn(jumpPosition)
|
focusOn(jumpPosition)
|
||||||
alphabetFocus = true
|
alphabetFocus = true
|
||||||
|
|
@ -422,7 +442,6 @@ fun AlphabetButtons(
|
||||||
letterClicked: (Char) -> Unit,
|
letterClicked: (Char) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
|
@ -434,17 +453,27 @@ fun AlphabetButtons(
|
||||||
listState.layoutInfo.visibleItemsInfo
|
listState.layoutInfo.visibleItemsInfo
|
||||||
.lastOrNull()
|
.lastOrNull()
|
||||||
?.index ?: -1
|
?.index ?: -1
|
||||||
if (index < firstVisibleItemIndex || index > lastVisibleItemIndex) {
|
if (index !in firstVisibleItemIndex..lastVisibleItemIndex) {
|
||||||
listState.animateScrollToItem(index)
|
listState.animateScrollToItem(index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Focus & interaction states for each letter button
|
||||||
val focusRequesters = remember { List(letters.length) { FocusRequester() } }
|
val focusRequesters = remember { List(letters.length) { FocusRequester() } }
|
||||||
|
val interactionSources = remember { List(letters.length) { MutableInteractionSource() } }
|
||||||
|
|
||||||
|
// Track if the entire alphabet picker component has focus
|
||||||
|
var alphabetPickerFocused by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
contentPadding = PaddingValues(4.dp),
|
contentPadding = PaddingValues(vertical = 1.1.dp, horizontal = 2.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(1.1.dp),
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier =
|
modifier =
|
||||||
modifier.focusProperties {
|
modifier
|
||||||
|
.onFocusChanged { focusState ->
|
||||||
|
alphabetPickerFocused = focusState.hasFocus
|
||||||
|
}.focusProperties {
|
||||||
onEnter = {
|
onEnter = {
|
||||||
focusRequesters[index.coerceIn(0, letters.length - 1)].tryRequestFocus()
|
focusRequesters[index.coerceIn(0, letters.length - 1)].tryRequestFocus()
|
||||||
}
|
}
|
||||||
|
|
@ -454,32 +483,68 @@ fun AlphabetButtons(
|
||||||
letters.length,
|
letters.length,
|
||||||
key = { letters[it] },
|
key = { letters[it] },
|
||||||
) { index ->
|
) { index ->
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = interactionSources[index]
|
||||||
val focused by interactionSource.collectIsFocusedAsState()
|
val focused by interactionSource.collectIsFocusedAsState()
|
||||||
TextButton(
|
|
||||||
|
val isCurrentLetter = letters[index] == currentLetter
|
||||||
|
// Apply alpha to individual items, but keep selected letter fully visible when picker is unfocused
|
||||||
|
val itemAlpha =
|
||||||
|
when {
|
||||||
|
isCurrentLetter && !alphabetPickerFocused -> 1f
|
||||||
|
alphabetPickerFocused -> .85f
|
||||||
|
else -> .25f
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show circle background for the current letter (or when focused)
|
||||||
|
// Wrap in Box with clipping to prevent focus indicator from overflowing
|
||||||
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.size(24.dp)
|
.size(14.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.alpha(itemAlpha),
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.size(14.dp)
|
||||||
.focusRequester(focusRequesters[index]),
|
.focusRequester(focusRequesters[index]),
|
||||||
contentPadding = PaddingValues(2.dp),
|
contentPadding = PaddingValues(0.dp), // No padding to maximize text space
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
onClick = {
|
onClick = {
|
||||||
letterClicked.invoke(letters[index])
|
letterClicked.invoke(letters[index])
|
||||||
},
|
},
|
||||||
) {
|
colors =
|
||||||
val color =
|
if (isCurrentLetter || focused) {
|
||||||
if (!focused && letters[index] == currentLetter) {
|
// Use default button colors for current letter or focused
|
||||||
MaterialTheme.colorScheme.tertiary
|
ButtonDefaults.colors()
|
||||||
} else {
|
} else {
|
||||||
LocalContentColor.current
|
// 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(
|
||||||
text = letters[index].toString(),
|
text = letters[index].toString(),
|
||||||
color = color,
|
color = color,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
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.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
|
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
|
|
@ -59,6 +60,7 @@ class LiveTvCollectionViewModel
|
||||||
val serverRepository: ServerRepository,
|
val serverRepository: ServerRepository,
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
val rememberTabManager: RememberTabManager,
|
val rememberTabManager: RememberTabManager,
|
||||||
|
val backdropService: BackdropService,
|
||||||
) : ViewModel(),
|
) : ViewModel(),
|
||||||
RememberTabManager by rememberTabManager {
|
RememberTabManager by rememberTabManager {
|
||||||
val recordingFolders = MutableLiveData<List<TabId>>()
|
val recordingFolders = MutableLiveData<List<TabId>>()
|
||||||
|
|
@ -106,6 +108,7 @@ fun CollectionFolderLiveTv(
|
||||||
LaunchedEffect(selectedTabIndex) {
|
LaunchedEffect(selectedTabIndex) {
|
||||||
logTab("livetv", selectedTabIndex)
|
logTab("livetv", selectedTabIndex)
|
||||||
viewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
|
viewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
|
||||||
|
viewModel.backdropService.clearBackdrop()
|
||||||
}
|
}
|
||||||
val onClickItem = { position: Int, item: BaseItem ->
|
val onClickItem = { position: Int, item: BaseItem ->
|
||||||
viewModel.navigationManager.navigateTo(item.destination())
|
viewModel.navigationManager.navigateTo(item.destination())
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ fun CollectionFolderMovie(
|
||||||
LaunchedEffect(selectedTabIndex) {
|
LaunchedEffect(selectedTabIndex) {
|
||||||
logTab("movie", selectedTabIndex)
|
logTab("movie", selectedTabIndex)
|
||||||
preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
|
preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
|
||||||
|
preferencesViewModel.backdropService.clearBackdrop()
|
||||||
}
|
}
|
||||||
|
|
||||||
var showHeader by rememberSaveable { mutableStateOf(true) }
|
var showHeader by rememberSaveable { mutableStateOf(true) }
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ fun CollectionFolderTv(
|
||||||
LaunchedEffect(selectedTabIndex) {
|
LaunchedEffect(selectedTabIndex) {
|
||||||
logTab("tv", selectedTabIndex)
|
logTab("tv", selectedTabIndex)
|
||||||
preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
|
preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
|
||||||
|
preferencesViewModel.backdropService.clearBackdrop()
|
||||||
}
|
}
|
||||||
|
|
||||||
val onClickItem = { item: BaseItem ->
|
val onClickItem = { item: BaseItem ->
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import com.github.damontecres.wholphin.data.ItemPlaybackDao
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.services.RefreshRateService
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.showToast
|
import com.github.damontecres.wholphin.ui.showToast
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
|
|
@ -58,6 +59,9 @@ class DebugViewModel
|
||||||
constructor(
|
constructor(
|
||||||
val serverRepository: ServerRepository,
|
val serverRepository: ServerRepository,
|
||||||
val itemPlaybackDao: ItemPlaybackDao,
|
val itemPlaybackDao: ItemPlaybackDao,
|
||||||
|
val refreshRateService: RefreshRateService,
|
||||||
|
val clientInfo: ClientInfo,
|
||||||
|
val deviceInfo: DeviceInfo,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val itemPlaybacks = MutableLiveData<List<ItemPlayback>>(listOf())
|
val itemPlaybacks = MutableLiveData<List<ItemPlayback>>(listOf())
|
||||||
val logcat = MutableLiveData<List<LogcatLine>>(listOf())
|
val logcat = MutableLiveData<List<LogcatLine>>(listOf())
|
||||||
|
|
@ -220,6 +224,7 @@ fun DebugPage(
|
||||||
listOf(
|
listOf(
|
||||||
"Version Name: ${pkgInfo.versionName}",
|
"Version Name: ${pkgInfo.versionName}",
|
||||||
"Version Code: ${pkgInfo.versionCodeLong}",
|
"Version Code: ${pkgInfo.versionCodeLong}",
|
||||||
|
"ClientInfo: ${viewModel.clientInfo}",
|
||||||
"Build type: ${BuildConfig.BUILD_TYPE}",
|
"Build type: ${BuildConfig.BUILD_TYPE}",
|
||||||
"Debug enabled: ${BuildConfig.DEBUG}",
|
"Debug enabled: ${BuildConfig.DEBUG}",
|
||||||
"ABIs: ${Build.SUPPORTED_ABIS.toList()}",
|
"ABIs: ${Build.SUPPORTED_ABIS.toList()}",
|
||||||
|
|
@ -233,6 +238,32 @@ fun DebugPage(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Device Information",
|
||||||
|
style = MaterialTheme.typography.displaySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
|
||||||
|
listOf(
|
||||||
|
"DeviceInfo: ${viewModel.deviceInfo}",
|
||||||
|
"Manufacturer: ${Build.MANUFACTURER}",
|
||||||
|
"Model: ${Build.MODEL}",
|
||||||
|
"Display Modes:",
|
||||||
|
*viewModel.refreshRateService.displayModes,
|
||||||
|
).forEach {
|
||||||
|
Text(
|
||||||
|
text = it.toString(),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
item {
|
item {
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import android.content.Context
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||||
import androidx.compose.material.icons.filled.ArrowForward
|
import androidx.compose.material.icons.filled.ArrowForward
|
||||||
|
import androidx.compose.material.icons.filled.Info
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
@ -43,6 +44,7 @@ data class MoreDialogActions(
|
||||||
* @param navigateTo a function to trigger a navigation
|
* @param navigateTo a function to trigger a navigation
|
||||||
* @param onChooseVersion callback to pick a version of the item
|
* @param onChooseVersion callback to pick a version of the item
|
||||||
* @param onChooseTracks callback to pick a track for the given type of the item
|
* @param onChooseTracks callback to pick a track for the given type of the item
|
||||||
|
* @param onShowOverview callback to show overview dialog with media information
|
||||||
*/
|
*/
|
||||||
fun buildMoreDialogItems(
|
fun buildMoreDialogItems(
|
||||||
context: Context,
|
context: Context,
|
||||||
|
|
@ -54,6 +56,7 @@ fun buildMoreDialogItems(
|
||||||
actions: MoreDialogActions,
|
actions: MoreDialogActions,
|
||||||
onChooseVersion: () -> Unit,
|
onChooseVersion: () -> Unit,
|
||||||
onChooseTracks: (MediaStreamType) -> Unit,
|
onChooseTracks: (MediaStreamType) -> Unit,
|
||||||
|
onShowOverview: () -> Unit,
|
||||||
): List<DialogItem> =
|
): List<DialogItem> =
|
||||||
buildList {
|
buildList {
|
||||||
add(
|
add(
|
||||||
|
|
@ -159,6 +162,16 @@ fun buildMoreDialogItems(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (item.data.mediaSources?.isNotEmpty() == true) {
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
context.getString(R.string.media_information),
|
||||||
|
Icons.Default.Info,
|
||||||
|
) {
|
||||||
|
onShowOverview.invoke()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
add(
|
add(
|
||||||
DialogItem(
|
DialogItem(
|
||||||
context.getString(R.string.play_with_transcoding),
|
context.getString(R.string.play_with_transcoding),
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,7 @@ fun FavoritesPage(
|
||||||
NavDrawerItem.Favorites.id,
|
NavDrawerItem.Favorites.id,
|
||||||
selectedTabIndex,
|
selectedTabIndex,
|
||||||
)
|
)
|
||||||
|
preferencesViewModel.backdropService.clearBackdrop()
|
||||||
}
|
}
|
||||||
var showHeader by rememberSaveable { mutableStateOf(true) }
|
var showHeader by rememberSaveable { mutableStateOf(true) }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,10 +50,11 @@ import androidx.tv.material3.Text
|
||||||
import androidx.tv.material3.surfaceColorAtElevation
|
import androidx.tv.material3.surfaceColorAtElevation
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.ui.DefaultItemFields
|
import com.github.damontecres.wholphin.ui.DefaultItemFields
|
||||||
|
import com.github.damontecres.wholphin.ui.TimeFormatter
|
||||||
import com.github.damontecres.wholphin.ui.cards.ItemCardImage
|
import com.github.damontecres.wholphin.ui.cards.ItemCardImage
|
||||||
import com.github.damontecres.wholphin.ui.components.DelayedDetailsBackdropImage
|
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogItem
|
import com.github.damontecres.wholphin.ui.components.DialogItem
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||||
|
|
@ -64,9 +65,11 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.components.OverviewText
|
import com.github.damontecres.wholphin.ui.components.OverviewText
|
||||||
import com.github.damontecres.wholphin.ui.enableMarquee
|
import com.github.damontecres.wholphin.ui.enableMarquee
|
||||||
import com.github.damontecres.wholphin.ui.ifElse
|
import com.github.damontecres.wholphin.ui.ifElse
|
||||||
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.roundMinutes
|
import com.github.damontecres.wholphin.ui.roundMinutes
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
import com.github.damontecres.wholphin.ui.util.LocalClock
|
||||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||||
import com.github.damontecres.wholphin.util.GetPlaylistItemsRequestHandler
|
import com.github.damontecres.wholphin.util.GetPlaylistItemsRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||||
|
|
@ -88,6 +91,7 @@ class PlaylistViewModel
|
||||||
constructor(
|
constructor(
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
|
private val backdropService: BackdropService,
|
||||||
) : ItemViewModel(api) {
|
) : ItemViewModel(api) {
|
||||||
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||||
val items = MutableLiveData<List<BaseItem?>>(listOf())
|
val items = MutableLiveData<List<BaseItem?>>(listOf())
|
||||||
|
|
@ -111,6 +115,12 @@ class PlaylistViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun updateBackdrop(item: BaseItem) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
backdropService.submit(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -146,6 +156,7 @@ fun PlaylistDetails(
|
||||||
playlist = it,
|
playlist = it,
|
||||||
items = items,
|
items = items,
|
||||||
focusRequester = focusRequester,
|
focusRequester = focusRequester,
|
||||||
|
onChangeBackdrop = viewModel::updateBackdrop,
|
||||||
onClickIndex = { index, _ ->
|
onClickIndex = { index, _ ->
|
||||||
viewModel.navigationManager.navigateTo(
|
viewModel.navigationManager.navigateTo(
|
||||||
Destination.PlaybackList(
|
Destination.PlaybackList(
|
||||||
|
|
@ -212,6 +223,7 @@ fun PlaylistDetailsContent(
|
||||||
onClickIndex: (Int, BaseItem) -> Unit,
|
onClickIndex: (Int, BaseItem) -> Unit,
|
||||||
onLongClickIndex: (Int, BaseItem) -> Unit,
|
onLongClickIndex: (Int, BaseItem) -> Unit,
|
||||||
onClickPlay: (shuffle: Boolean) -> Unit,
|
onClickPlay: (shuffle: Boolean) -> Unit,
|
||||||
|
onChangeBackdrop: (BaseItem) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
focusRequester: FocusRequester = remember { FocusRequester() },
|
focusRequester: FocusRequester = remember { FocusRequester() },
|
||||||
) {
|
) {
|
||||||
|
|
@ -219,13 +231,15 @@ fun PlaylistDetailsContent(
|
||||||
var focusedIndex by remember { mutableIntStateOf(savedIndex) }
|
var focusedIndex by remember { mutableIntStateOf(savedIndex) }
|
||||||
val focus = remember { FocusRequester() }
|
val focus = remember { FocusRequester() }
|
||||||
val focusedItem = items.getOrNull(focusedIndex)
|
val focusedItem = items.getOrNull(focusedIndex)
|
||||||
|
LaunchedEffect(focusedItem) {
|
||||||
|
focusedItem?.let(onChangeBackdrop)
|
||||||
|
}
|
||||||
|
|
||||||
val playButtonFocusRequester = remember { FocusRequester() }
|
val playButtonFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
DelayedDetailsBackdropImage(focusedItem)
|
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
modifier =
|
modifier =
|
||||||
|
|
@ -395,10 +409,22 @@ fun PlaylistItem(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
item?.data?.runTimeTicks?.ticks?.roundMinutes?.let {
|
item?.data?.runTimeTicks?.ticks?.roundMinutes?.let { duration ->
|
||||||
|
val now = LocalClock.current.now
|
||||||
|
val endTimeStr =
|
||||||
|
remember(item, now) {
|
||||||
|
val endTime = now.toLocalTime().plusSeconds(duration.inWholeSeconds)
|
||||||
|
TimeFormatter.format(endTime)
|
||||||
|
}
|
||||||
|
Column {
|
||||||
Text(
|
Text(
|
||||||
text = it.toString(),
|
text = duration.toString(),
|
||||||
)
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.ends_at, endTimeStr),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
|
@ -20,6 +20,7 @@ import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -133,7 +134,7 @@ fun PlaylistList(
|
||||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||||
elevation = 10.dp,
|
elevation = 10.dp,
|
||||||
) {
|
) {
|
||||||
val playlistName = rememberTextFieldState()
|
var playlistName by rememberSaveable { mutableStateOf("") }
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||||
Column(
|
Column(
|
||||||
|
|
@ -148,7 +149,8 @@ fun PlaylistList(
|
||||||
text = stringResource(R.string.name),
|
text = stringResource(R.string.name),
|
||||||
)
|
)
|
||||||
EditTextBox(
|
EditTextBox(
|
||||||
state = playlistName,
|
value = playlistName,
|
||||||
|
onValueChange = { playlistName = it },
|
||||||
keyboardOptions =
|
keyboardOptions =
|
||||||
KeyboardOptions(
|
KeyboardOptions(
|
||||||
capitalization = KeyboardCapitalization.Words,
|
capitalization = KeyboardCapitalization.Words,
|
||||||
|
|
@ -156,10 +158,17 @@ fun PlaylistList(
|
||||||
keyboardType = KeyboardType.Text,
|
keyboardType = KeyboardType.Text,
|
||||||
imeAction = ImeAction.Done,
|
imeAction = ImeAction.Done,
|
||||||
),
|
),
|
||||||
onKeyboardAction = {
|
keyboardActions =
|
||||||
|
KeyboardActions(
|
||||||
|
onDone = {
|
||||||
showCreateDialog = false
|
showCreateDialog = false
|
||||||
onCreatePlaylist.invoke(playlistName.text.toString())
|
onCreatePlaylist.invoke(playlistName)
|
||||||
},
|
},
|
||||||
|
),
|
||||||
|
// onKeyboardAction = {
|
||||||
|
// showCreateDialog = false
|
||||||
|
// onCreatePlaylist.invoke(playlistName.text.toString())
|
||||||
|
// },
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.focusRequester(focusRequester)
|
.focusRequester(focusRequester)
|
||||||
|
|
@ -168,9 +177,9 @@ fun PlaylistList(
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
showCreateDialog = false
|
showCreateDialog = false
|
||||||
onCreatePlaylist.invoke(playlistName.text.toString())
|
onCreatePlaylist.invoke(playlistName)
|
||||||
},
|
},
|
||||||
enabled = playlistName.text.isNotNullOrBlank(),
|
enabled = playlistName.isNotNullOrBlank(),
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Text(text = stringResource(R.string.submit))
|
Text(text = stringResource(R.string.submit))
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,7 @@ import androidx.lifecycle.compose.LifecycleStartEffect
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.ChosenStreams
|
import com.github.damontecres.wholphin.data.ChosenStreams
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.chooseSource
|
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.components.DetailsBackdropImage
|
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
|
|
@ -57,6 +55,7 @@ import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.MediaType
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import org.jellyfin.sdk.model.serializer.toUUID
|
import org.jellyfin.sdk.model.serializer.toUUID
|
||||||
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
|
@ -157,7 +156,7 @@ fun EpisodeDetails(
|
||||||
watched = ep.data.userData?.played ?: false,
|
watched = ep.data.userData?.played ?: false,
|
||||||
favorite = ep.data.userData?.isFavorite ?: false,
|
favorite = ep.data.userData?.isFavorite ?: false,
|
||||||
seriesId = ep.data.seriesId,
|
seriesId = ep.data.seriesId,
|
||||||
sourceId = chosenStreams?.sourceId,
|
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
|
||||||
actions = moreActions,
|
actions = moreActions,
|
||||||
onChooseVersion = {
|
onChooseVersion = {
|
||||||
chooseVersion =
|
chooseVersion =
|
||||||
|
|
@ -174,7 +173,8 @@ fun EpisodeDetails(
|
||||||
moreDialog = null
|
moreDialog = null
|
||||||
},
|
},
|
||||||
onChooseTracks = { type ->
|
onChooseTracks = { type ->
|
||||||
chooseSource(
|
viewModel.streamChoiceService
|
||||||
|
.chooseSource(
|
||||||
ep.data,
|
ep.data,
|
||||||
chosenStreams?.itemPlayback,
|
chosenStreams?.itemPlayback,
|
||||||
)?.let { source ->
|
)?.let { source ->
|
||||||
|
|
@ -194,6 +194,18 @@ fun EpisodeDetails(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onShowOverview = {
|
||||||
|
val source = chosenStreams?.source ?: ep.data.mediaSources?.firstOrNull()
|
||||||
|
if (source != null) {
|
||||||
|
overviewDialog =
|
||||||
|
ItemDetailsDialogInfo(
|
||||||
|
title = ep.name ?: context.getString(R.string.unknown),
|
||||||
|
overview = ep.data.overview,
|
||||||
|
genres = ep.data.genres.orEmpty(),
|
||||||
|
files = listOf(source),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -283,7 +295,6 @@ fun EpisodeDetailsContent(
|
||||||
focusRequesters.getOrNull(position)?.tryRequestFocus()
|
focusRequesters.getOrNull(position)?.tryRequestFocus()
|
||||||
}
|
}
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
DetailsBackdropImage(ep)
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp),
|
contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp),
|
||||||
|
|
|
||||||
|
|
@ -58,9 +58,7 @@ fun EpisodeDetailsHeader(
|
||||||
EpisodeQuickDetails(dto)
|
EpisodeQuickDetails(dto)
|
||||||
|
|
||||||
VideoStreamDetails(
|
VideoStreamDetails(
|
||||||
preferences = preferences,
|
chosenStreams = chosenStreams,
|
||||||
dto = dto,
|
|
||||||
itemPlayback = chosenStreams?.itemPlayback,
|
|
||||||
modifier = Modifier.padding(bottom = padding),
|
modifier = Modifier.padding(bottom = padding),
|
||||||
)
|
)
|
||||||
dto.taglines?.firstOrNull()?.let { tagline ->
|
dto.taglines?.firstOrNull()?.let { tagline ->
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,12 @@ import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||||
import com.github.damontecres.wholphin.preferences.ThemeSongVolume
|
import com.github.damontecres.wholphin.preferences.ThemeSongVolume
|
||||||
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||||
import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
||||||
|
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||||
|
|
@ -44,8 +47,11 @@ class EpisodeViewModel
|
||||||
private val navigationManager: NavigationManager,
|
private val navigationManager: NavigationManager,
|
||||||
val serverRepository: ServerRepository,
|
val serverRepository: ServerRepository,
|
||||||
val itemPlaybackRepository: ItemPlaybackRepository,
|
val itemPlaybackRepository: ItemPlaybackRepository,
|
||||||
|
val streamChoiceService: StreamChoiceService,
|
||||||
private val themeSongPlayer: ThemeSongPlayer,
|
private val themeSongPlayer: ThemeSongPlayer,
|
||||||
private val favoriteWatchManager: FavoriteWatchManager,
|
private val favoriteWatchManager: FavoriteWatchManager,
|
||||||
|
private val userPreferencesService: UserPreferencesService,
|
||||||
|
private val backdropService: BackdropService,
|
||||||
@Assisted val itemId: UUID,
|
@Assisted val itemId: UUID,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
|
|
@ -85,12 +91,14 @@ class EpisodeViewModel
|
||||||
"Error fetching movie",
|
"Error fetching movie",
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
|
val prefs = userPreferencesService.getCurrent()
|
||||||
val item = fetchAndSetItem().await()
|
val item = fetchAndSetItem().await()
|
||||||
val result = itemPlaybackRepository.getSelectedTracks(item.id, item)
|
val result = itemPlaybackRepository.getSelectedTracks(item.id, item, prefs)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
this@EpisodeViewModel.item.value = item
|
this@EpisodeViewModel.item.value = item
|
||||||
chosenStreams.value = result
|
chosenStreams.value = result
|
||||||
loading.value = LoadingState.Success
|
loading.value = LoadingState.Success
|
||||||
|
backdropService.submit(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,10 +123,12 @@ class EpisodeViewModel
|
||||||
sourceId: UUID,
|
sourceId: UUID,
|
||||||
) {
|
) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
|
val prefs = userPreferencesService.getCurrent()
|
||||||
|
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
||||||
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
|
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
|
||||||
val chosen =
|
val chosen =
|
||||||
result?.let {
|
result?.let {
|
||||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result)
|
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
chosenStreams.value = chosen
|
chosenStreams.value = chosen
|
||||||
|
|
@ -133,6 +143,8 @@ class EpisodeViewModel
|
||||||
type: MediaStreamType,
|
type: MediaStreamType,
|
||||||
) {
|
) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
|
val prefs = userPreferencesService.getCurrent()
|
||||||
|
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
||||||
val result =
|
val result =
|
||||||
itemPlaybackRepository.saveTrackSelection(
|
itemPlaybackRepository.saveTrackSelection(
|
||||||
item = item,
|
item = item,
|
||||||
|
|
@ -142,7 +154,7 @@ class EpisodeViewModel
|
||||||
)
|
)
|
||||||
val chosen =
|
val chosen =
|
||||||
result?.let {
|
result?.let {
|
||||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result)
|
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
chosenStreams.value = chosen
|
chosenStreams.value = chosen
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,10 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
@ -20,40 +22,78 @@ import androidx.tv.material3.Text
|
||||||
import androidx.tv.material3.contentColorFor
|
import androidx.tv.material3.contentColorFor
|
||||||
import androidx.tv.material3.surfaceColorAtElevation
|
import androidx.tv.material3.surfaceColorAtElevation
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun Program(
|
fun Program(
|
||||||
|
guideStart: LocalDateTime,
|
||||||
program: TvProgram,
|
program: TvProgram,
|
||||||
focused: Boolean,
|
focused: Boolean,
|
||||||
|
colorCode: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val background =
|
val background =
|
||||||
if (focused) {
|
if (focused) {
|
||||||
MaterialTheme.colorScheme.inverseSurface
|
MaterialTheme.colorScheme.inverseSurface
|
||||||
} else {
|
} else if (colorCode) {
|
||||||
program.category?.color
|
program.category?.color
|
||||||
?: MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)
|
?: MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)
|
||||||
}
|
}
|
||||||
val textColor = MaterialTheme.colorScheme.contentColorFor(background)
|
val textColor = MaterialTheme.colorScheme.contentColorFor(background)
|
||||||
|
val startedBeforeGuide = program.start.isBefore(guideStart)
|
||||||
|
val shape =
|
||||||
|
remember(startedBeforeGuide) {
|
||||||
|
val cornerSize = 4.dp
|
||||||
|
if (startedBeforeGuide) {
|
||||||
|
RoundedCornerShape(
|
||||||
|
topEnd = cornerSize,
|
||||||
|
bottomEnd = cornerSize,
|
||||||
|
topStart = 0.dp,
|
||||||
|
bottomStart = 0.dp,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
RoundedCornerShape(cornerSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val title = program.name ?: program.id.toString()
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
modifier
|
modifier
|
||||||
.padding(2.dp)
|
.padding(2.dp)
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(
|
.background(
|
||||||
background,
|
color = background,
|
||||||
shape = RoundedCornerShape(4.dp),
|
shape = shape,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
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(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(4.dp),
|
.padding(start = 2.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = program.name ?: program.id.toString(),
|
text = title,
|
||||||
color = textColor,
|
color = textColor,
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
|
|
@ -75,6 +115,7 @@ fun Program(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
RecordingMarker(
|
RecordingMarker(
|
||||||
isRecording = program.isRecording,
|
isRecording = program.isRecording,
|
||||||
isSeriesRecording = program.isSeriesRecording,
|
isSeriesRecording = program.isSeriesRecording,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.detail.livetv
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
|
@ -9,6 +10,7 @@ import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.WholphinApplication
|
import com.github.damontecres.wholphin.WholphinApplication
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.ui.AppColors
|
import com.github.damontecres.wholphin.ui.AppColors
|
||||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||||
|
|
@ -23,7 +25,13 @@ import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
@ -34,7 +42,9 @@ import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.api.GetProgramsDto
|
import org.jellyfin.sdk.model.api.GetProgramsDto
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
import org.jellyfin.sdk.model.api.ItemFields
|
||||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
import org.jellyfin.sdk.model.api.TimerInfoDto
|
import org.jellyfin.sdk.model.api.TimerInfoDto
|
||||||
import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest
|
import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
|
|
@ -45,11 +55,13 @@ import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.Duration.Companion.minutes
|
import kotlin.time.Duration.Companion.minutes
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
const val MAX_HOURS = 48L
|
const val MAX_HOURS = 48L
|
||||||
|
|
||||||
|
@OptIn(FlowPreview::class)
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class LiveTvViewModel
|
class LiveTvViewModel
|
||||||
@Inject
|
@Inject
|
||||||
|
|
@ -57,15 +69,15 @@ class LiveTvViewModel
|
||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
val api: ApiClient,
|
val api: ApiClient,
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
|
val preferences: DataStore<AppPreferences>,
|
||||||
private val serverRepository: ServerRepository,
|
private val serverRepository: ServerRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||||
|
|
||||||
lateinit var guideStart: LocalDateTime
|
|
||||||
private set
|
|
||||||
private lateinit var channelsIdToIndex: Map<UUID, Int>
|
private lateinit var channelsIdToIndex: Map<UUID, Int>
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
val guideTimes = MutableLiveData<List<LocalDateTime>>(buildGuideTimes())
|
||||||
val channels = MutableLiveData<List<TvChannel>>()
|
val channels = MutableLiveData<List<TvChannel>>()
|
||||||
val channelProgramCount = mutableMapOf<UUID, Int>()
|
val channelProgramCount = mutableMapOf<UUID, Int>()
|
||||||
val programs = MutableLiveData<FetchedPrograms>()
|
val programs = MutableLiveData<FetchedPrograms>()
|
||||||
|
|
@ -75,11 +87,25 @@ class LiveTvViewModel
|
||||||
|
|
||||||
private val range = 100
|
private val range = 100
|
||||||
|
|
||||||
fun init(firstLoad: Boolean) {
|
init {
|
||||||
guideStart = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS)
|
viewModelScope.launchIO {
|
||||||
if (!firstLoad) {
|
preferences.data
|
||||||
loading.value = LoadingState.Loading
|
.map {
|
||||||
|
it.interfacePreferences.liveTvPreferences.let {
|
||||||
|
Pair(it.sortByRecentlyWatched, it.favoriteChannelsAtBeginning)
|
||||||
}
|
}
|
||||||
|
}.distinctUntilChanged()
|
||||||
|
.debounce { 500.milliseconds }
|
||||||
|
.collectLatest {
|
||||||
|
Timber.v("Init due to pref change")
|
||||||
|
loading.setValueOnMain(LoadingState.Pending)
|
||||||
|
init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun init() {
|
||||||
|
val guideStart = guideTimes.value!!.first()
|
||||||
viewModelScope.launch(
|
viewModelScope.launch(
|
||||||
Dispatchers.IO +
|
Dispatchers.IO +
|
||||||
LoadingExceptionHandler(
|
LoadingExceptionHandler(
|
||||||
|
|
@ -87,11 +113,26 @@ class LiveTvViewModel
|
||||||
"Could not fetch channels",
|
"Could not fetch channels",
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
|
val prefs =
|
||||||
|
(preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance())
|
||||||
|
.interfacePreferences.liveTvPreferences
|
||||||
val channelData by api.liveTvApi.getLiveTvChannels(
|
val channelData by api.liveTvApi.getLiveTvChannels(
|
||||||
GetLiveTvChannelsRequest(
|
GetLiveTvChannelsRequest(
|
||||||
startIndex = 0,
|
startIndex = 0,
|
||||||
userId = serverRepository.currentUser.value?.id,
|
userId = serverRepository.currentUser.value?.id,
|
||||||
enableFavoriteSorting = true,
|
enableFavoriteSorting = prefs.favoriteChannelsAtBeginning,
|
||||||
|
sortBy =
|
||||||
|
if (prefs.sortByRecentlyWatched) {
|
||||||
|
listOf(ItemSortBy.DATE_PLAYED)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
sortOrder =
|
||||||
|
if (prefs.sortByRecentlyWatched) {
|
||||||
|
SortOrder.DESCENDING
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
addCurrentProgram = false,
|
addCurrentProgram = false,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -111,7 +152,7 @@ class LiveTvViewModel
|
||||||
// Initially, quickly load the first 10 channels (only some are visible immediately), then below will load more
|
// Initially, quickly load the first 10 channels (only some are visible immediately), then below will load more
|
||||||
// This makes the guide appear faster, and load more usable data in the background
|
// This makes the guide appear faster, and load more usable data in the background
|
||||||
val initial = 10
|
val initial = 10
|
||||||
fetchPrograms(channels, 0..<initial.coerceAtMost(channels.size))
|
fetchPrograms(guideStart, channels, 0..<initial.coerceAtMost(channels.size))
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
this@LiveTvViewModel.channels.value = channels
|
this@LiveTvViewModel.channels.value = channels
|
||||||
|
|
@ -119,21 +160,35 @@ class LiveTvViewModel
|
||||||
}
|
}
|
||||||
// Now load the full range
|
// Now load the full range
|
||||||
if (channels.size > initial) {
|
if (channels.size > initial) {
|
||||||
fetchPrograms(channels, 0..<range.coerceAtMost(channels.size))
|
fetchPrograms(guideStart, channels, 0..<range.coerceAtMost(channels.size))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun buildGuideTimes() =
|
||||||
|
buildList {
|
||||||
|
val start = LocalDateTime.now().roundDownToHalfHour()
|
||||||
|
add(start)
|
||||||
|
if (start.minute == 30) {
|
||||||
|
add(start.plusMinutes(30))
|
||||||
|
}
|
||||||
|
repeat(MAX_HOURS.toInt() - 1) {
|
||||||
|
add(last().plusHours(1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun fetchProgramsWithLoading(
|
private suspend fun fetchProgramsWithLoading(
|
||||||
channels: List<TvChannel>,
|
channels: List<TvChannel>,
|
||||||
range: IntRange,
|
range: IntRange,
|
||||||
) {
|
) {
|
||||||
loading.setValueOnMain(LoadingState.Loading)
|
loading.setValueOnMain(LoadingState.Loading)
|
||||||
fetchPrograms(channels, range)
|
val guideStart = guideTimes.value!!.first()
|
||||||
|
fetchPrograms(guideStart, channels, range)
|
||||||
loading.setValueOnMain(LoadingState.Success)
|
loading.setValueOnMain(LoadingState.Success)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun fetchPrograms(
|
private suspend fun fetchPrograms(
|
||||||
|
guideStart: LocalDateTime,
|
||||||
channels: List<TvChannel>,
|
channels: List<TvChannel>,
|
||||||
range: IntRange,
|
range: IntRange,
|
||||||
) = mutex.withLock {
|
) = mutex.withLock {
|
||||||
|
|
@ -148,6 +203,7 @@ class LiveTvViewModel
|
||||||
channelIds = channelsToFetch.map { it.id },
|
channelIds = channelsToFetch.map { it.id },
|
||||||
sortBy = listOf(ItemSortBy.START_DATE),
|
sortBy = listOf(ItemSortBy.START_DATE),
|
||||||
userId = serverRepository.currentUser.value?.id,
|
userId = serverRepository.currentUser.value?.id,
|
||||||
|
fields = listOf(ItemFields.OVERVIEW),
|
||||||
)
|
)
|
||||||
val fetchedPrograms =
|
val fetchedPrograms =
|
||||||
api.liveTvApi
|
api.liveTvApi
|
||||||
|
|
@ -173,6 +229,12 @@ class LiveTvViewModel
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
// Clean up name/subtitles by collapsing whitespace (including newlines) into single spaces
|
||||||
|
val name = (dto.seriesName ?: dto.name)?.replace(Regex("\\s+"), " ")
|
||||||
|
val subtitle =
|
||||||
|
dto.episodeTitle
|
||||||
|
.takeIf { dto.isSeries ?: false }
|
||||||
|
?.replace(Regex("\\s+"), " ")
|
||||||
val p =
|
val p =
|
||||||
TvProgram(
|
TvProgram(
|
||||||
id = dto.id,
|
id = dto.id,
|
||||||
|
|
@ -186,8 +248,10 @@ class LiveTvViewModel
|
||||||
).coerceAtLeast(0f),
|
).coerceAtLeast(0f),
|
||||||
endHours = hoursBetween(guideStart, dto.endDate!!),
|
endHours = hoursBetween(guideStart, dto.endDate!!),
|
||||||
duration = dto.runTimeTicks!!.ticks,
|
duration = dto.runTimeTicks!!.ticks,
|
||||||
name = dto.seriesName ?: dto.name,
|
name = name,
|
||||||
subtitle = dto.episodeTitle.takeIf { dto.isSeries ?: false },
|
subtitle = subtitle,
|
||||||
|
overview = dto.overview,
|
||||||
|
officialRating = dto.officialRating,
|
||||||
seasonEpisode =
|
seasonEpisode =
|
||||||
if (dto.indexNumber != null && dto.parentIndexNumber != null) {
|
if (dto.indexNumber != null && dto.parentIndexNumber != null) {
|
||||||
SeasonEpisode(
|
SeasonEpisode(
|
||||||
|
|
@ -474,11 +538,13 @@ data class TvProgram(
|
||||||
val duration: Duration,
|
val duration: Duration,
|
||||||
val name: String?,
|
val name: String?,
|
||||||
val subtitle: String?,
|
val subtitle: String?,
|
||||||
|
val overview: String? = null,
|
||||||
val seasonEpisode: SeasonEpisode?,
|
val seasonEpisode: SeasonEpisode?,
|
||||||
val isRecording: Boolean,
|
val isRecording: Boolean,
|
||||||
val isSeriesRecording: Boolean,
|
val isSeriesRecording: Boolean,
|
||||||
val isRepeat: Boolean,
|
val isRepeat: Boolean,
|
||||||
val category: ProgramCategory?,
|
val category: ProgramCategory?,
|
||||||
|
val officialRating: String? = null,
|
||||||
) {
|
) {
|
||||||
val isFake = category == ProgramCategory.FAKE
|
val isFake = category == ProgramCategory.FAKE
|
||||||
|
|
||||||
|
|
@ -500,6 +566,7 @@ data class TvProgram(
|
||||||
duration = ((endHours - startHours) * 60).toInt().minutes,
|
duration = ((endHours - startHours) * 60).toInt().minutes,
|
||||||
name = NO_DATA,
|
name = NO_DATA,
|
||||||
subtitle = null,
|
subtitle = null,
|
||||||
|
overview = null,
|
||||||
seasonEpisode = null,
|
seasonEpisode = null,
|
||||||
isRecording = false,
|
isRecording = false,
|
||||||
isSeriesRecording = false,
|
isSeriesRecording = false,
|
||||||
|
|
@ -524,3 +591,8 @@ data class FetchedPrograms(
|
||||||
val programs: List<TvProgram>,
|
val programs: List<TvProgram>,
|
||||||
val programsByChannel: Map<UUID, List<TvProgram>>,
|
val programsByChannel: Map<UUID, List<TvProgram>>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun LocalDateTime.roundDownToHalfHour(): LocalDateTime {
|
||||||
|
val min = minute % 30L
|
||||||
|
return minusMinutes(min).truncatedTo(ChronoUnit.MINUTES)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.text.format.DateUtils
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.focusable
|
import androidx.compose.foundation.focusable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
|
@ -13,6 +16,7 @@ import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
|
@ -24,6 +28,7 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusDirection
|
import androidx.compose.ui.focus.FocusDirection
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusProperties
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
|
|
@ -39,10 +44,15 @@ import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Surface
|
import androidx.tv.material3.Surface
|
||||||
import androidx.tv.material3.SurfaceDefaults
|
import androidx.tv.material3.SurfaceDefaults
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
import com.github.damontecres.wholphin.preferences.LiveTvPreferences
|
||||||
import com.github.damontecres.wholphin.ui.components.CircularProgress
|
import com.github.damontecres.wholphin.ui.components.CircularProgress
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||||
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.rememberPosition
|
import com.github.damontecres.wholphin.ui.rememberPosition
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
|
@ -66,16 +76,21 @@ fun TvGuideGrid(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: LiveTvViewModel = hiltViewModel(),
|
viewModel: LiveTvViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
var firstLoad by rememberSaveable { mutableStateOf(true) }
|
val scope = rememberCoroutineScope()
|
||||||
LaunchedEffect(Unit) {
|
// var firstLoad by rememberSaveable { mutableStateOf(true) }
|
||||||
viewModel.init(firstLoad)
|
// LaunchedEffect(Unit) {
|
||||||
firstLoad = false
|
// viewModel.init(firstLoad)
|
||||||
}
|
// firstLoad = false
|
||||||
|
// }
|
||||||
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
|
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
|
||||||
val channels by viewModel.channels.observeAsState(listOf())
|
val channels by viewModel.channels.observeAsState(listOf())
|
||||||
val programs by viewModel.programs.observeAsState(FetchedPrograms(0..0, listOf(), mapOf()))
|
val programs by viewModel.programs.observeAsState(FetchedPrograms(0..0, listOf(), mapOf()))
|
||||||
// val programsByChannel by viewModel.programsByChannel.observeAsState(mapOf())
|
// val programsByChannel by viewModel.programsByChannel.observeAsState(mapOf())
|
||||||
// val fetchedRange by viewModel.fetchedRange.observeAsState(0..0)
|
// val fetchedRange by viewModel.fetchedRange.observeAsState(0..0)
|
||||||
|
val preferences by viewModel.preferences.data
|
||||||
|
.collectAsState(AppPreferences.getDefaultInstance())
|
||||||
|
val tvPrefs = preferences.interfacePreferences.liveTvPreferences
|
||||||
|
var showViewOptions by remember { mutableStateOf(false) }
|
||||||
when (val state = loading) {
|
when (val state = loading) {
|
||||||
is LoadingState.Error -> {
|
is LoadingState.Error -> {
|
||||||
ErrorMessage(state, modifier)
|
ErrorMessage(state, modifier)
|
||||||
|
|
@ -90,25 +105,59 @@ fun TvGuideGrid(
|
||||||
LoadingState.Success,
|
LoadingState.Success,
|
||||||
-> {
|
-> {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val guideTimes by viewModel.guideTimes.observeAsState(listOf())
|
||||||
val fetchedItem by viewModel.fetchedItem.observeAsState(null)
|
val fetchedItem by viewModel.fetchedItem.observeAsState(null)
|
||||||
val loadingItem by viewModel.fetchingItem.observeAsState(LoadingState.Pending)
|
val loadingItem by viewModel.fetchingItem.observeAsState(LoadingState.Pending)
|
||||||
var showItemDialog by remember { mutableStateOf<Int?>(null) }
|
var showItemDialog by remember { mutableStateOf<Int?>(null) }
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val buttonFocusRequester = remember { FocusRequester() }
|
||||||
if (requestFocusAfterLoading) {
|
if (requestFocusAfterLoading) {
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
focusRequester.tryRequestFocus()
|
focusRequester.tryRequestFocus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Column(modifier = modifier) {
|
var focusedPosition by rememberPosition(0, 0)
|
||||||
|
val focusedProgram =
|
||||||
|
remember(focusedPosition) {
|
||||||
|
focusedPosition.let {
|
||||||
|
val channelId = channels.getOrNull(it.row)?.id
|
||||||
|
programs.programsByChannel[channelId]?.getOrNull(it.column)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
if (channels.isEmpty()) {
|
if (channels.isEmpty()) {
|
||||||
ErrorMessage("Live TV is enabled, but no channels were found.", null)
|
ErrorMessage("Live TV is enabled, but no channels were found.", null)
|
||||||
} else {
|
} else {
|
||||||
|
AnimatedVisibility(tvPrefs.showHeader) {
|
||||||
|
TvGuideHeader(
|
||||||
|
program = focusedProgram,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(top = 24.dp, bottom = 16.dp, start = 32.dp)
|
||||||
|
.fillMaxHeight(.30f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AnimatedVisibility(focusedPosition.row < 1) {
|
||||||
|
ExpandableFaButton(
|
||||||
|
title = R.string.view_options,
|
||||||
|
iconStringRes = R.string.fa_sliders,
|
||||||
|
onClick = { showViewOptions = true },
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(start = tvGuideDimensions.channelWidth)
|
||||||
|
.focusRequester(buttonFocusRequester),
|
||||||
|
)
|
||||||
|
}
|
||||||
TvGuideGridContent(
|
TvGuideGridContent(
|
||||||
|
preferences = tvPrefs,
|
||||||
loading = state is LoadingState.Loading,
|
loading = state is LoadingState.Loading,
|
||||||
channels = channels,
|
channels = channels,
|
||||||
programs = programs,
|
programs = programs,
|
||||||
channelProgramCount = viewModel.channelProgramCount,
|
channelProgramCount = viewModel.channelProgramCount,
|
||||||
start = viewModel.guideStart,
|
guideTimes = guideTimes,
|
||||||
onClickChannel = { index, channel ->
|
onClickChannel = { index, channel ->
|
||||||
viewModel.navigationManager.navigateTo(
|
viewModel.navigationManager.navigateTo(
|
||||||
Destination.Playback(
|
Destination.Playback(
|
||||||
|
|
@ -118,6 +167,7 @@ fun TvGuideGrid(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onFocus = {
|
onFocus = {
|
||||||
|
focusedPosition = it
|
||||||
onRowPosition.invoke(it.row)
|
onRowPosition.invoke(it.row)
|
||||||
viewModel.onFocusChannel(it)
|
viewModel.onFocusChannel(it)
|
||||||
},
|
},
|
||||||
|
|
@ -144,6 +194,7 @@ fun TvGuideGrid(
|
||||||
showItemDialog = index
|
showItemDialog = index
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
buttonFocusRequester = buttonFocusRequester,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|
@ -192,24 +243,49 @@ fun TvGuideGrid(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (showViewOptions) {
|
||||||
|
LiveTvViewOptionsDialog(
|
||||||
|
preferences = preferences,
|
||||||
|
onDismissRequest = { showViewOptions = false },
|
||||||
|
onViewOptionsChange = { newPrefs ->
|
||||||
|
scope.launchIO {
|
||||||
|
viewModel.preferences.updateData {
|
||||||
|
newPrefs
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val tvGuideDimensions =
|
||||||
|
ProgramGuideDimensions(
|
||||||
|
timelineHourWidth = 240.dp,
|
||||||
|
timelineHeight = 32.dp,
|
||||||
|
channelWidth = 120.dp,
|
||||||
|
channelHeight = 64.dp,
|
||||||
|
currentTimeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TvGuideGridContent(
|
fun TvGuideGridContent(
|
||||||
|
preferences: LiveTvPreferences,
|
||||||
loading: Boolean,
|
loading: Boolean,
|
||||||
channels: List<TvChannel>,
|
channels: List<TvChannel>,
|
||||||
programs: FetchedPrograms,
|
programs: FetchedPrograms,
|
||||||
channelProgramCount: Map<UUID, Int>,
|
channelProgramCount: Map<UUID, Int>,
|
||||||
start: LocalDateTime,
|
guideTimes: List<LocalDateTime>,
|
||||||
onClickChannel: (Int, TvChannel) -> Unit,
|
onClickChannel: (Int, TvChannel) -> Unit,
|
||||||
onClickProgram: (Int, TvProgram) -> Unit,
|
onClickProgram: (Int, TvProgram) -> Unit,
|
||||||
onFocus: (RowColumn) -> Unit,
|
onFocus: (RowColumn) -> Unit,
|
||||||
|
buttonFocusRequester: FocusRequester,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val state = rememberSaveableProgramGuideState()
|
val state = rememberSaveableProgramGuideState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val guideStart = guideTimes.first()
|
||||||
|
|
||||||
var focusedItem by rememberPosition(RowColumn(0, 0))
|
var focusedItem by rememberPosition(RowColumn(0, 0))
|
||||||
val focusedChannelIndex = focusedItem.row
|
val focusedChannelIndex = focusedItem.row
|
||||||
|
|
@ -229,25 +305,19 @@ fun TvGuideGridContent(
|
||||||
state.animateToProgram(focusedProgramIndex, Alignment.Center)
|
state.animateToProgram(focusedProgramIndex, Alignment.Center)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val dimensions =
|
|
||||||
ProgramGuideDimensions(
|
|
||||||
timelineHourWidth = 240.dp,
|
|
||||||
timelineHeight = 32.dp,
|
|
||||||
channelWidth = 120.dp,
|
|
||||||
channelHeight = 64.dp,
|
|
||||||
currentTimeWidth = 2.dp,
|
|
||||||
)
|
|
||||||
var gridHasFocus by rememberSaveable { mutableStateOf(false) }
|
var gridHasFocus by rememberSaveable { mutableStateOf(false) }
|
||||||
var channelColumnFocused by rememberSaveable { mutableStateOf(false) }
|
var channelColumnFocused by rememberSaveable { mutableStateOf(false) }
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
ProgramGuide(
|
ProgramGuide(
|
||||||
state = state,
|
state = state,
|
||||||
dimensions = dimensions,
|
dimensions = tvGuideDimensions,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.onFocusChanged {
|
.onFocusChanged {
|
||||||
gridHasFocus = it.hasFocus
|
gridHasFocus = it.hasFocus
|
||||||
|
}.focusProperties {
|
||||||
|
up = buttonFocusRequester
|
||||||
}.focusable()
|
}.focusable()
|
||||||
.onPreviewKeyEvent {
|
.onPreviewKeyEvent {
|
||||||
if (it.type == KeyEventType.KeyUp) {
|
if (it.type == KeyEventType.KeyUp) {
|
||||||
|
|
@ -291,7 +361,7 @@ fun TvGuideGridContent(
|
||||||
|
|
||||||
Key.DirectionUp -> {
|
Key.DirectionUp -> {
|
||||||
if (item.row <= 0) {
|
if (item.row <= 0) {
|
||||||
focusManager.moveFocus(FocusDirection.Up)
|
// focusManager.moveFocus(FocusDirection.Up)
|
||||||
null
|
null
|
||||||
} else {
|
} else {
|
||||||
val newChannelIndex = item.row - 1
|
val newChannelIndex = item.row - 1
|
||||||
|
|
@ -438,7 +508,7 @@ fun TvGuideGridContent(
|
||||||
currentTime(
|
currentTime(
|
||||||
layoutInfo = {
|
layoutInfo = {
|
||||||
ProgramGuideItem.CurrentTime(
|
ProgramGuideItem.CurrentTime(
|
||||||
hoursBetween(start, LocalDateTime.now()),
|
hoursBetween(guideStart, LocalDateTime.now()),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
|
@ -455,11 +525,18 @@ fun TvGuideGridContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
timeline(
|
timeline(
|
||||||
count = MAX_HOURS.toInt(),
|
count = guideTimes.size,
|
||||||
layoutInfo = { index ->
|
layoutInfo = { index ->
|
||||||
|
val start = guideTimes[index]
|
||||||
|
val end =
|
||||||
|
if (index < guideTimes.lastIndex) {
|
||||||
|
guideTimes[index + 1]
|
||||||
|
} else {
|
||||||
|
start.plusHours(1)
|
||||||
|
}
|
||||||
ProgramGuideItem.Timeline(
|
ProgramGuideItem.Timeline(
|
||||||
startHour = index.toFloat(),
|
startHour = hoursBetween(guideStart, start),
|
||||||
endHour = index + 1f,
|
endHour = hoursBetween(guideStart, end),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { index ->
|
) { index ->
|
||||||
|
|
@ -477,16 +554,12 @@ fun TvGuideGridContent(
|
||||||
shape = RoundedCornerShape(4.dp),
|
shape = RoundedCornerShape(4.dp),
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
val differentDay =
|
val guideTime = guideTimes[index]
|
||||||
start.toLocalDate() !=
|
val differentDay = guideTime.toLocalDate() != guideStart.toLocalDate()
|
||||||
start
|
|
||||||
.plusHours(index.toLong())
|
|
||||||
.toLocalDate()
|
|
||||||
val time =
|
val time =
|
||||||
DateUtils.formatDateTime(
|
DateUtils.formatDateTime(
|
||||||
context,
|
context,
|
||||||
start
|
guideTime
|
||||||
.plusHours(index.toLong())
|
|
||||||
.toInstant(OffsetDateTime.now().offset)
|
.toInstant(OffsetDateTime.now().offset)
|
||||||
.epochSecond * 1000,
|
.epochSecond * 1000,
|
||||||
DateUtils.FORMAT_SHOW_TIME or if (differentDay) DateUtils.FORMAT_SHOW_WEEKDAY else 0,
|
DateUtils.FORMAT_SHOW_TIME or if (differentDay) DateUtils.FORMAT_SHOW_WEEKDAY else 0,
|
||||||
|
|
@ -516,7 +589,7 @@ fun TvGuideGridContent(
|
||||||
val program = programs.programs[programIndex]
|
val program = programs.programs[programIndex]
|
||||||
val focused =
|
val focused =
|
||||||
gridHasFocus && !channelColumnFocused && programIndex == focusedProgramIndex
|
gridHasFocus && !channelColumnFocused && programIndex == focusedProgramIndex
|
||||||
Program(program, focused, Modifier)
|
Program(guideStart, program, focused, preferences.colorCodePrograms, Modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
channels(
|
channels(
|
||||||
|
|
|
||||||
|
|
@ -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.RemoteTrailer
|
||||||
import com.github.damontecres.wholphin.data.model.Trailer
|
import com.github.damontecres.wholphin.data.model.Trailer
|
||||||
import com.github.damontecres.wholphin.data.model.aspectRatioFloat
|
import com.github.damontecres.wholphin.data.model.aspectRatioFloat
|
||||||
import com.github.damontecres.wholphin.data.model.chooseSource
|
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.TrailerService
|
import com.github.damontecres.wholphin.services.TrailerService
|
||||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||||
|
|
@ -55,7 +54,6 @@ import com.github.damontecres.wholphin.ui.cards.ExtrasRow
|
||||||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||||
import com.github.damontecres.wholphin.ui.cards.PersonRow
|
import com.github.damontecres.wholphin.ui.cards.PersonRow
|
||||||
import com.github.damontecres.wholphin.ui.cards.SeasonCard
|
import com.github.damontecres.wholphin.ui.cards.SeasonCard
|
||||||
import com.github.damontecres.wholphin.ui.components.DetailsBackdropImage
|
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
|
|
@ -83,6 +81,7 @@ import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.MediaType
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import org.jellyfin.sdk.model.serializer.toUUID
|
import org.jellyfin.sdk.model.serializer.toUUID
|
||||||
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
|
@ -204,7 +203,7 @@ fun MovieDetails(
|
||||||
watched = movie.data.userData?.played ?: false,
|
watched = movie.data.userData?.played ?: false,
|
||||||
favorite = movie.data.userData?.isFavorite ?: false,
|
favorite = movie.data.userData?.isFavorite ?: false,
|
||||||
seriesId = null,
|
seriesId = null,
|
||||||
sourceId = chosenStreams?.sourceId,
|
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
|
||||||
actions = moreActions,
|
actions = moreActions,
|
||||||
onChooseVersion = {
|
onChooseVersion = {
|
||||||
chooseVersion =
|
chooseVersion =
|
||||||
|
|
@ -221,7 +220,8 @@ fun MovieDetails(
|
||||||
moreDialog = null
|
moreDialog = null
|
||||||
},
|
},
|
||||||
onChooseTracks = { type ->
|
onChooseTracks = { type ->
|
||||||
chooseSource(
|
viewModel.streamChoiceService
|
||||||
|
.chooseSource(
|
||||||
movie.data,
|
movie.data,
|
||||||
chosenStreams?.itemPlayback,
|
chosenStreams?.itemPlayback,
|
||||||
)?.let { source ->
|
)?.let { source ->
|
||||||
|
|
@ -241,6 +241,17 @@ fun MovieDetails(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onShowOverview = {
|
||||||
|
overviewDialog =
|
||||||
|
ItemDetailsDialogInfo(
|
||||||
|
title =
|
||||||
|
movie.name
|
||||||
|
?: context.getString(R.string.unknown),
|
||||||
|
overview = movie.data.overview,
|
||||||
|
genres = movie.data.genres.orEmpty(),
|
||||||
|
files = movie.data.mediaSources.orEmpty(),
|
||||||
|
)
|
||||||
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -384,7 +395,6 @@ fun MovieDetailsContent(
|
||||||
focusRequesters.getOrNull(position)?.tryRequestFocus()
|
focusRequesters.getOrNull(position)?.tryRequestFocus()
|
||||||
}
|
}
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
DetailsBackdropImage(movie)
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp),
|
contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp),
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -53,6 +54,7 @@ fun MovieDetailsHeader(
|
||||||
text = movie.name ?: "",
|
text = movie.name ?: "",
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
style = MaterialTheme.typography.displaySmall,
|
style = MaterialTheme.typography.displaySmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.fillMaxWidth(.75f),
|
modifier = Modifier.fillMaxWidth(.75f),
|
||||||
|
|
@ -70,9 +72,7 @@ fun MovieDetailsHeader(
|
||||||
}
|
}
|
||||||
|
|
||||||
VideoStreamDetails(
|
VideoStreamDetails(
|
||||||
preferences = preferences,
|
chosenStreams = chosenStreams,
|
||||||
dto = dto,
|
|
||||||
itemPlayback = chosenStreams?.itemPlayback,
|
|
||||||
modifier = Modifier.padding(bottom = padding),
|
modifier = Modifier.padding(bottom = padding),
|
||||||
)
|
)
|
||||||
dto.taglines?.firstOrNull()?.let { tagline ->
|
dto.taglines?.firstOrNull()?.let { tagline ->
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,15 @@ import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||||
import com.github.damontecres.wholphin.data.model.Person
|
import com.github.damontecres.wholphin.data.model.Person
|
||||||
import com.github.damontecres.wholphin.data.model.Trailer
|
import com.github.damontecres.wholphin.data.model.Trailer
|
||||||
import com.github.damontecres.wholphin.preferences.ThemeSongVolume
|
import com.github.damontecres.wholphin.preferences.ThemeSongVolume
|
||||||
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.ExtrasService
|
import com.github.damontecres.wholphin.services.ExtrasService
|
||||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.services.PeopleFavorites
|
import com.github.damontecres.wholphin.services.PeopleFavorites
|
||||||
|
import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||||
import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
||||||
import com.github.damontecres.wholphin.services.TrailerService
|
import com.github.damontecres.wholphin.services.TrailerService
|
||||||
|
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
|
@ -54,11 +57,14 @@ class MovieViewModel
|
||||||
private val navigationManager: NavigationManager,
|
private val navigationManager: NavigationManager,
|
||||||
val serverRepository: ServerRepository,
|
val serverRepository: ServerRepository,
|
||||||
val itemPlaybackRepository: ItemPlaybackRepository,
|
val itemPlaybackRepository: ItemPlaybackRepository,
|
||||||
|
val streamChoiceService: StreamChoiceService,
|
||||||
private val themeSongPlayer: ThemeSongPlayer,
|
private val themeSongPlayer: ThemeSongPlayer,
|
||||||
private val favoriteWatchManager: FavoriteWatchManager,
|
private val favoriteWatchManager: FavoriteWatchManager,
|
||||||
private val peopleFavorites: PeopleFavorites,
|
private val peopleFavorites: PeopleFavorites,
|
||||||
private val trailerService: TrailerService,
|
private val trailerService: TrailerService,
|
||||||
private val extrasService: ExtrasService,
|
private val extrasService: ExtrasService,
|
||||||
|
private val userPreferencesService: UserPreferencesService,
|
||||||
|
private val backdropService: BackdropService,
|
||||||
@Assisted val itemId: UUID,
|
@Assisted val itemId: UUID,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
|
|
@ -104,11 +110,17 @@ class MovieViewModel
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
val item = fetchAndSetItem().await()
|
val item = fetchAndSetItem().await()
|
||||||
val result = itemPlaybackRepository.getSelectedTracks(item.id, item)
|
val result =
|
||||||
|
itemPlaybackRepository.getSelectedTracks(
|
||||||
|
item.id,
|
||||||
|
item,
|
||||||
|
userPreferencesService.getCurrent(),
|
||||||
|
)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
this@MovieViewModel.item.value = item
|
this@MovieViewModel.item.value = item
|
||||||
chosenStreams.value = result
|
chosenStreams.value = result
|
||||||
loading.value = LoadingState.Success
|
loading.value = LoadingState.Success
|
||||||
|
backdropService.submit(item)
|
||||||
}
|
}
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val trailers = trailerService.getTrailers(item)
|
val trailers = trailerService.getTrailers(item)
|
||||||
|
|
@ -172,10 +184,12 @@ class MovieViewModel
|
||||||
sourceId: UUID,
|
sourceId: UUID,
|
||||||
) {
|
) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
|
val prefs = userPreferencesService.getCurrent()
|
||||||
|
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
||||||
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
|
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
|
||||||
val chosen =
|
val chosen =
|
||||||
result?.let {
|
result?.let {
|
||||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result)
|
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
chosenStreams.value = chosen
|
chosenStreams.value = chosen
|
||||||
|
|
@ -190,6 +204,8 @@ class MovieViewModel
|
||||||
type: MediaStreamType,
|
type: MediaStreamType,
|
||||||
) {
|
) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
|
val prefs = userPreferencesService.getCurrent()
|
||||||
|
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
||||||
val result =
|
val result =
|
||||||
itemPlaybackRepository.saveTrackSelection(
|
itemPlaybackRepository.saveTrackSelection(
|
||||||
item = item,
|
item = item,
|
||||||
|
|
@ -199,7 +215,7 @@ class MovieViewModel
|
||||||
)
|
)
|
||||||
val chosen =
|
val chosen =
|
||||||
result?.let {
|
result?.let {
|
||||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result)
|
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
chosenStreams.value = chosen
|
chosenStreams.value = chosen
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,7 @@ fun FocusedEpisodeHeader(
|
||||||
|
|
||||||
if (dto != null) {
|
if (dto != null) {
|
||||||
VideoStreamDetails(
|
VideoStreamDetails(
|
||||||
preferences = preferences,
|
chosenStreams = chosenStreams,
|
||||||
dto = dto,
|
|
||||||
itemPlayback = chosenStreams?.itemPlayback,
|
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,6 @@ import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||||
import com.github.damontecres.wholphin.ui.cards.PersonRow
|
import com.github.damontecres.wholphin.ui.cards.PersonRow
|
||||||
import com.github.damontecres.wholphin.ui.cards.SeasonCard
|
import com.github.damontecres.wholphin.ui.cards.SeasonCard
|
||||||
import com.github.damontecres.wholphin.ui.components.ConfirmDialog
|
import com.github.damontecres.wholphin.ui.components.ConfirmDialog
|
||||||
import com.github.damontecres.wholphin.ui.components.DetailsBackdropImage
|
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogItem
|
import com.github.damontecres.wholphin.ui.components.DialogItem
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||||
|
|
@ -320,8 +319,6 @@ fun SeriesDetailsContent(
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
DetailsBackdropImage(series)
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ import androidx.lifecycle.compose.LifecycleStartEffect
|
||||||
import androidx.lifecycle.map
|
import androidx.lifecycle.map
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.chooseSource
|
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||||
|
|
@ -47,12 +46,12 @@ import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.UseSerializers
|
import kotlinx.serialization.UseSerializers
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
|
||||||
import org.jellyfin.sdk.model.api.MediaType
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
import org.jellyfin.sdk.model.api.PersonKind
|
import org.jellyfin.sdk.model.api.PersonKind
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||||
import org.jellyfin.sdk.model.serializer.toUUID
|
import org.jellyfin.sdk.model.serializer.toUUID
|
||||||
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
@ -219,7 +218,7 @@ fun SeriesOverview(
|
||||||
watched = ep.data.userData?.played ?: false,
|
watched = ep.data.userData?.played ?: false,
|
||||||
favorite = ep.data.userData?.isFavorite ?: false,
|
favorite = ep.data.userData?.isFavorite ?: false,
|
||||||
seriesId = series.id,
|
seriesId = series.id,
|
||||||
sourceId = chosenStreams?.sourceId,
|
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
|
||||||
actions =
|
actions =
|
||||||
MoreDialogActions(
|
MoreDialogActions(
|
||||||
navigateTo = viewModel::navigateTo,
|
navigateTo = viewModel::navigateTo,
|
||||||
|
|
@ -257,7 +256,8 @@ fun SeriesOverview(
|
||||||
moreDialog = null
|
moreDialog = null
|
||||||
},
|
},
|
||||||
onChooseTracks = { type ->
|
onChooseTracks = { type ->
|
||||||
chooseSource(
|
viewModel.streamChoiceService
|
||||||
|
.chooseSource(
|
||||||
ep.data,
|
ep.data,
|
||||||
chosenStreams?.itemPlayback,
|
chosenStreams?.itemPlayback,
|
||||||
)?.let { source ->
|
)?.let { source ->
|
||||||
|
|
@ -277,6 +277,15 @@ fun SeriesOverview(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onShowOverview = {
|
||||||
|
overviewDialog =
|
||||||
|
ItemDetailsDialogInfo(
|
||||||
|
title = ep.name ?: context.getString(R.string.unknown),
|
||||||
|
overview = ep.data.overview,
|
||||||
|
genres = ep.data.genres.orEmpty(),
|
||||||
|
files = ep.data.mediaSources.orEmpty(),
|
||||||
|
)
|
||||||
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -288,13 +297,6 @@ fun SeriesOverview(
|
||||||
chosenStreams = chosenStreams,
|
chosenStreams = chosenStreams,
|
||||||
peopleInEpisode = peopleInEpisode,
|
peopleInEpisode = peopleInEpisode,
|
||||||
position = position,
|
position = position,
|
||||||
backdropImageUrl =
|
|
||||||
remember {
|
|
||||||
viewModel.imageUrl(
|
|
||||||
series.id,
|
|
||||||
ImageType.BACKDROP,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
firstItemFocusRequester = firstItemFocusRequester,
|
firstItemFocusRequester = firstItemFocusRequester,
|
||||||
episodeRowFocusRequester = episodeRowFocusRequester,
|
episodeRowFocusRequester = episodeRowFocusRequester,
|
||||||
castCrewRowFocusRequester = castCrewRowFocusRequester,
|
castCrewRowFocusRequester = castCrewRowFocusRequester,
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,6 @@ import com.github.damontecres.wholphin.ui.AspectRatios
|
||||||
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
||||||
import com.github.damontecres.wholphin.ui.cards.BannerCard
|
import com.github.damontecres.wholphin.ui.cards.BannerCard
|
||||||
import com.github.damontecres.wholphin.ui.cards.PersonRow
|
import com.github.damontecres.wholphin.ui.cards.PersonRow
|
||||||
import com.github.damontecres.wholphin.ui.components.DetailsBackdropImage
|
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.components.SeriesName
|
import com.github.damontecres.wholphin.ui.components.SeriesName
|
||||||
|
|
@ -75,7 +74,6 @@ fun SeriesOverviewContent(
|
||||||
chosenStreams: ChosenStreams?,
|
chosenStreams: ChosenStreams?,
|
||||||
peopleInEpisode: List<Person>,
|
peopleInEpisode: List<Person>,
|
||||||
position: SeriesOverviewPosition,
|
position: SeriesOverviewPosition,
|
||||||
backdropImageUrl: String?,
|
|
||||||
firstItemFocusRequester: FocusRequester,
|
firstItemFocusRequester: FocusRequester,
|
||||||
episodeRowFocusRequester: FocusRequester,
|
episodeRowFocusRequester: FocusRequester,
|
||||||
castCrewRowFocusRequester: FocusRequester,
|
castCrewRowFocusRequester: FocusRequester,
|
||||||
|
|
@ -112,7 +110,6 @@ fun SeriesOverviewContent(
|
||||||
modifier
|
modifier
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
DetailsBackdropImage(backdropImageUrl)
|
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier =
|
modifier =
|
||||||
|
|
@ -218,9 +215,7 @@ fun SeriesOverviewContent(
|
||||||
item = episode,
|
item = episode,
|
||||||
aspectRatio =
|
aspectRatio =
|
||||||
episode
|
episode
|
||||||
?.data
|
?.aspectRatio
|
||||||
?.primaryImageAspectRatio
|
|
||||||
?.toFloat()
|
|
||||||
?.coerceAtLeast(AspectRatios.FOUR_THREE)
|
?.coerceAtLeast(AspectRatios.FOUR_THREE)
|
||||||
?: (AspectRatios.WIDE),
|
?: (AspectRatios.WIDE),
|
||||||
cornerText = cornerText,
|
cornerText = cornerText,
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,15 @@ import com.github.damontecres.wholphin.data.model.Person
|
||||||
import com.github.damontecres.wholphin.data.model.Trailer
|
import com.github.damontecres.wholphin.data.model.Trailer
|
||||||
import com.github.damontecres.wholphin.preferences.ThemeSongVolume
|
import com.github.damontecres.wholphin.preferences.ThemeSongVolume
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.ExtrasService
|
import com.github.damontecres.wholphin.services.ExtrasService
|
||||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.services.PeopleFavorites
|
import com.github.damontecres.wholphin.services.PeopleFavorites
|
||||||
|
import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||||
import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
||||||
import com.github.damontecres.wholphin.services.TrailerService
|
import com.github.damontecres.wholphin.services.TrailerService
|
||||||
|
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||||
import com.github.damontecres.wholphin.ui.detail.ItemViewModel
|
import com.github.damontecres.wholphin.ui.detail.ItemViewModel
|
||||||
import com.github.damontecres.wholphin.ui.equalsNotNull
|
import com.github.damontecres.wholphin.ui.equalsNotNull
|
||||||
|
|
@ -70,6 +73,9 @@ class SeriesViewModel
|
||||||
private val peopleFavorites: PeopleFavorites,
|
private val peopleFavorites: PeopleFavorites,
|
||||||
private val trailerService: TrailerService,
|
private val trailerService: TrailerService,
|
||||||
private val extrasService: ExtrasService,
|
private val extrasService: ExtrasService,
|
||||||
|
val streamChoiceService: StreamChoiceService,
|
||||||
|
private val userPreferencesService: UserPreferencesService,
|
||||||
|
private val backdropService: BackdropService,
|
||||||
) : ItemViewModel(api) {
|
) : ItemViewModel(api) {
|
||||||
private lateinit var seriesId: UUID
|
private lateinit var seriesId: UUID
|
||||||
private lateinit var prefs: UserPreferences
|
private lateinit var prefs: UserPreferences
|
||||||
|
|
@ -99,6 +105,7 @@ class SeriesViewModel
|
||||||
) + Dispatchers.IO,
|
) + Dispatchers.IO,
|
||||||
) {
|
) {
|
||||||
val item = fetchItem(seriesId)
|
val item = fetchItem(seriesId)
|
||||||
|
backdropService.submit(item)
|
||||||
val seasons = getSeasons(item)
|
val seasons = getSeasons(item)
|
||||||
|
|
||||||
// If a particular season was requested, fetch those episodes, otherwise get the first season
|
// If a particular season was requested, fetch those episodes, otherwise get the first season
|
||||||
|
|
@ -328,6 +335,8 @@ class SeriesViewModel
|
||||||
episodes.value = eps
|
episodes.value = eps
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Kind of hack to ensure the backdrop is reloaded if needed
|
||||||
|
item.value?.let { backdropService.submit(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -372,7 +381,12 @@ class SeriesViewModel
|
||||||
chosenStreamsJob?.cancel()
|
chosenStreamsJob?.cancel()
|
||||||
chosenStreamsJob =
|
chosenStreamsJob =
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val result = itemPlaybackRepository.getSelectedTracks(itemId, item)
|
val result =
|
||||||
|
itemPlaybackRepository.getSelectedTracks(
|
||||||
|
itemId,
|
||||||
|
item,
|
||||||
|
userPreferencesService.getCurrent(),
|
||||||
|
)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
chosenStreams.value = result
|
chosenStreams.value = result
|
||||||
}
|
}
|
||||||
|
|
@ -384,10 +398,12 @@ class SeriesViewModel
|
||||||
sourceId: UUID,
|
sourceId: UUID,
|
||||||
) {
|
) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
|
val prefs = userPreferencesService.getCurrent()
|
||||||
|
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
||||||
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
|
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
|
||||||
val chosen =
|
val chosen =
|
||||||
result?.let {
|
result?.let {
|
||||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result)
|
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
chosenStreams.value = chosen
|
chosenStreams.value = chosen
|
||||||
|
|
@ -402,6 +418,8 @@ class SeriesViewModel
|
||||||
type: MediaStreamType,
|
type: MediaStreamType,
|
||||||
) {
|
) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
|
val prefs = userPreferencesService.getCurrent()
|
||||||
|
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
||||||
val result =
|
val result =
|
||||||
itemPlaybackRepository.saveTrackSelection(
|
itemPlaybackRepository.saveTrackSelection(
|
||||||
item = item,
|
item = item,
|
||||||
|
|
@ -411,7 +429,7 @@ class SeriesViewModel
|
||||||
)
|
)
|
||||||
val chosen =
|
val chosen =
|
||||||
result?.let {
|
result?.let {
|
||||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result)
|
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
chosenStreams.value = chosen
|
chosenStreams.value = chosen
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.focusRestorer
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.key.onKeyEvent
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
|
@ -50,7 +51,6 @@ import com.github.damontecres.wholphin.ui.Cards
|
||||||
import com.github.damontecres.wholphin.ui.cards.BannerCard
|
import com.github.damontecres.wholphin.ui.cards.BannerCard
|
||||||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||||
import com.github.damontecres.wholphin.ui.components.CircularProgress
|
import com.github.damontecres.wholphin.ui.components.CircularProgress
|
||||||
import com.github.damontecres.wholphin.ui.components.DelayedDetailsBackdropImage
|
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||||
import com.github.damontecres.wholphin.ui.components.EpisodeQuickDetails
|
import com.github.damontecres.wholphin.ui.components.EpisodeQuickDetails
|
||||||
|
|
@ -67,12 +67,16 @@ import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
||||||
import com.github.damontecres.wholphin.ui.ifElse
|
import com.github.damontecres.wholphin.ui.ifElse
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.playable
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.MediaType
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -157,8 +161,12 @@ fun HomePage(
|
||||||
items = dialogItems,
|
items = dialogItems,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
onClickPlay = { _, item ->
|
||||||
|
viewModel.navigationManager.navigateTo(Destination.Playback(item))
|
||||||
|
},
|
||||||
loadingState = refreshing,
|
loadingState = refreshing,
|
||||||
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
||||||
|
onUpdateBackdrop = viewModel::updateBackdrop,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
dialog?.let { params ->
|
dialog?.let { params ->
|
||||||
|
|
@ -193,7 +201,9 @@ fun HomePageContent(
|
||||||
homeRows: List<HomeRowLoadingState>,
|
homeRows: List<HomeRowLoadingState>,
|
||||||
onClickItem: (RowColumn, BaseItem) -> Unit,
|
onClickItem: (RowColumn, BaseItem) -> Unit,
|
||||||
onLongClickItem: (RowColumn, BaseItem) -> Unit,
|
onLongClickItem: (RowColumn, BaseItem) -> Unit,
|
||||||
|
onClickPlay: (RowColumn, BaseItem) -> Unit,
|
||||||
showClock: Boolean,
|
showClock: Boolean,
|
||||||
|
onUpdateBackdrop: (BaseItem) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onFocusPosition: ((RowColumn) -> Unit)? = null,
|
onFocusPosition: ((RowColumn) -> Unit)? = null,
|
||||||
loadingState: LoadingState? = null,
|
loadingState: LoadingState? = null,
|
||||||
|
|
@ -240,12 +250,10 @@ fun HomePageContent(
|
||||||
LaunchedEffect(position) {
|
LaunchedEffect(position) {
|
||||||
listState.animateScrollToItem(position.row)
|
listState.animateScrollToItem(position.row)
|
||||||
}
|
}
|
||||||
|
LaunchedEffect(focusedItem) {
|
||||||
|
focusedItem?.let(onUpdateBackdrop)
|
||||||
|
}
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
DelayedDetailsBackdropImage(
|
|
||||||
item = focusedItem,
|
|
||||||
modifier = Modifier,
|
|
||||||
)
|
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
HomePageHeader(
|
HomePageHeader(
|
||||||
item = focusedItem,
|
item = focusedItem,
|
||||||
|
|
@ -264,7 +272,18 @@ fun HomePageContent(
|
||||||
top = 0.dp,
|
top = 0.dp,
|
||||||
bottom = Cards.height2x3,
|
bottom = Cards.height2x3,
|
||||||
),
|
),
|
||||||
modifier = Modifier.focusRestorer(),
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.focusRestorer()
|
||||||
|
.onKeyEvent {
|
||||||
|
val item = focusedItem
|
||||||
|
if (isPlayKeyUp(it) && item?.type?.playable == true) {
|
||||||
|
Timber.v("Clicked play on ${item.id}")
|
||||||
|
onClickPlay.invoke(position, item)
|
||||||
|
return@onKeyEvent true
|
||||||
|
}
|
||||||
|
return@onKeyEvent false
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
itemsIndexed(homeRows) { rowIndex, row ->
|
itemsIndexed(homeRows) { rowIndex, row ->
|
||||||
when (val r = row) {
|
when (val r = row) {
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,12 @@ import com.github.damontecres.wholphin.data.NavDrawerItemRepository
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.DatePlayedService
|
import com.github.damontecres.wholphin.services.DatePlayedService
|
||||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||||
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
|
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
|
|
@ -58,6 +60,7 @@ class HomeViewModel
|
||||||
val navDrawerItemRepository: NavDrawerItemRepository,
|
val navDrawerItemRepository: NavDrawerItemRepository,
|
||||||
private val favoriteWatchManager: FavoriteWatchManager,
|
private val favoriteWatchManager: FavoriteWatchManager,
|
||||||
private val datePlayedService: DatePlayedService,
|
private val datePlayedService: DatePlayedService,
|
||||||
|
private val backdropService: BackdropService,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val loadingState = MutableLiveData<LoadingState>(LoadingState.Pending)
|
val loadingState = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||||
val refreshState = MutableLiveData<LoadingState>(LoadingState.Pending)
|
val refreshState = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||||
|
|
@ -316,6 +319,12 @@ class HomeViewModel
|
||||||
init(preferences)
|
init(preferences)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun updateBackdrop(item: BaseItem) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
backdropService.submit(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val supportedLatestCollectionTypes =
|
val supportedLatestCollectionTypes =
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
|
@ -196,7 +195,8 @@ fun SearchPage(
|
||||||
val episodes by viewModel.episodes.observeAsState(SearchResult.NoQuery)
|
val episodes by viewModel.episodes.observeAsState(SearchResult.NoQuery)
|
||||||
val seerrResults by viewModel.seerrResults.observeAsState(SearchResult.NoQuery)
|
val seerrResults by viewModel.seerrResults.observeAsState(SearchResult.NoQuery)
|
||||||
|
|
||||||
val query = rememberTextFieldState()
|
// val query = rememberTextFieldState()
|
||||||
|
var query by rememberSaveable { mutableStateOf("") }
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
var position by rememberPosition()
|
var position by rememberPosition()
|
||||||
|
|
@ -204,7 +204,7 @@ fun SearchPage(
|
||||||
|
|
||||||
LaunchedEffect(query) {
|
LaunchedEffect(query) {
|
||||||
delay(750L)
|
delay(750L)
|
||||||
viewModel.search(query.text.toString())
|
viewModel.search(query)
|
||||||
}
|
}
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
focusRequester.tryRequestFocus()
|
focusRequester.tryRequestFocus()
|
||||||
|
|
@ -238,9 +238,10 @@ fun SearchPage(
|
||||||
focusManager.moveFocus(FocusDirection.Next)
|
focusManager.moveFocus(FocusDirection.Next)
|
||||||
}
|
}
|
||||||
SearchEditTextBox(
|
SearchEditTextBox(
|
||||||
state = query,
|
value = query,
|
||||||
|
onValueChange = { query = it },
|
||||||
onSearchClick = {
|
onSearchClick = {
|
||||||
viewModel.search(query.text.toString())
|
viewModel.search(query)
|
||||||
searchClicked = true
|
searchClicked = true
|
||||||
},
|
},
|
||||||
modifier =
|
modifier =
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,59 @@
|
||||||
package com.github.damontecres.wholphin.ui.nav
|
package com.github.damontecres.wholphin.ui.nav
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.BlendMode
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||||
import androidx.navigation3.runtime.NavEntry
|
import androidx.navigation3.runtime.NavEntry
|
||||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||||
import androidx.navigation3.ui.NavDisplay
|
import androidx.navigation3.ui.NavDisplay
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import coil3.request.ImageRequest
|
||||||
|
import coil3.request.transitionFactory
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
|
import com.github.damontecres.wholphin.preferences.BackdropStyle
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.ui.CrossFadeFactory
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class ApplicationContentViewModel
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
val backdropService: BackdropService,
|
||||||
|
) : ViewModel() {
|
||||||
|
fun clearBackdrop() {
|
||||||
|
viewModelScope.launchIO { backdropService.clearBackdrop() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is generally the root composable of the of the app
|
* This is generally the root composable of the of the app
|
||||||
|
|
@ -25,7 +67,125 @@ fun ApplicationContent(
|
||||||
navigationManager: NavigationManager,
|
navigationManager: NavigationManager,
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: ApplicationContentViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
|
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(
|
NavDisplay(
|
||||||
backStack = navigationManager.backStack,
|
backStack = navigationManager.backStack,
|
||||||
onBack = { navigationManager.goBack() },
|
onBack = { navigationManager.goBack() },
|
||||||
|
|
@ -42,7 +202,8 @@ fun ApplicationContent(
|
||||||
DestinationContent(
|
DestinationContent(
|
||||||
destination = key,
|
destination = key,
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
modifier = modifier.fillMaxSize(),
|
onClearBackdrop = viewModel::clearBackdrop,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
} else if (user != null && server != null) {
|
} else if (user != null && server != null) {
|
||||||
NavDrawer(
|
NavDrawer(
|
||||||
|
|
@ -50,7 +211,8 @@ fun ApplicationContent(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
user = user,
|
user = user,
|
||||||
server = server,
|
server = server,
|
||||||
modifier = modifier,
|
onClearBackdrop = viewModel::clearBackdrop,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
ErrorMessage("Trying to go to $key without a user logged in", null)
|
ErrorMessage("Trying to go to $key without a user logged in", null)
|
||||||
|
|
@ -59,3 +221,4 @@ fun ApplicationContent(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.github.damontecres.wholphin.ui.nav
|
package com.github.damontecres.wholphin.ui.nav
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.data.filter.DefaultForGenresFilterOptions
|
import com.github.damontecres.wholphin.data.filter.DefaultForGenresFilterOptions
|
||||||
|
|
@ -44,8 +45,12 @@ import timber.log.Timber
|
||||||
fun DestinationContent(
|
fun DestinationContent(
|
||||||
destination: Destination,
|
destination: Destination,
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
|
onClearBackdrop: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
if (destination.fullScreen) {
|
||||||
|
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||||
|
}
|
||||||
when (destination) {
|
when (destination) {
|
||||||
is Destination.Home -> {
|
is Destination.Home -> {
|
||||||
HomePage(
|
HomePage(
|
||||||
|
|
@ -125,6 +130,7 @@ fun DestinationContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
BaseItemKind.BOX_SET -> {
|
BaseItemKind.BOX_SET -> {
|
||||||
|
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||||
CollectionFolderBoxSet(
|
CollectionFolderBoxSet(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
itemId = destination.itemId,
|
itemId = destination.itemId,
|
||||||
|
|
@ -136,6 +142,7 @@ fun DestinationContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
BaseItemKind.PLAYLIST -> {
|
BaseItemKind.PLAYLIST -> {
|
||||||
|
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||||
PlaylistDetails(
|
PlaylistDetails(
|
||||||
destination = destination,
|
destination = destination,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|
@ -143,6 +150,7 @@ fun DestinationContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
BaseItemKind.COLLECTION_FOLDER -> {
|
BaseItemKind.COLLECTION_FOLDER -> {
|
||||||
|
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||||
CollectionFolder(
|
CollectionFolder(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
destination = destination,
|
destination = destination,
|
||||||
|
|
@ -154,6 +162,7 @@ fun DestinationContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
BaseItemKind.FOLDER -> {
|
BaseItemKind.FOLDER -> {
|
||||||
|
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||||
CollectionFolder(
|
CollectionFolder(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
destination = destination,
|
destination = destination,
|
||||||
|
|
@ -165,6 +174,7 @@ fun DestinationContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
BaseItemKind.USER_VIEW -> {
|
BaseItemKind.USER_VIEW -> {
|
||||||
|
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||||
CollectionFolder(
|
CollectionFolder(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
destination = destination,
|
destination = destination,
|
||||||
|
|
@ -176,6 +186,7 @@ fun DestinationContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
BaseItemKind.PERSON -> {
|
BaseItemKind.PERSON -> {
|
||||||
|
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||||
PersonPage(
|
PersonPage(
|
||||||
preferences,
|
preferences,
|
||||||
destination,
|
destination,
|
||||||
|
|
@ -191,6 +202,7 @@ fun DestinationContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
is Destination.FilteredCollection -> {
|
is Destination.FilteredCollection -> {
|
||||||
|
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||||
CollectionFolderGeneric(
|
CollectionFolderGeneric(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
itemId = destination.itemId,
|
itemId = destination.itemId,
|
||||||
|
|
@ -204,6 +216,7 @@ fun DestinationContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
is Destination.Recordings -> {
|
is Destination.Recordings -> {
|
||||||
|
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||||
CollectionFolderRecordings(
|
CollectionFolderRecordings(
|
||||||
preferences,
|
preferences,
|
||||||
destination.itemId,
|
destination.itemId,
|
||||||
|
|
@ -213,6 +226,7 @@ fun DestinationContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
is Destination.ItemGrid -> {
|
is Destination.ItemGrid -> {
|
||||||
|
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||||
ItemGrid(
|
ItemGrid(
|
||||||
destination,
|
destination,
|
||||||
modifier,
|
modifier,
|
||||||
|
|
@ -220,6 +234,7 @@ fun DestinationContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
Destination.Favorites -> {
|
Destination.Favorites -> {
|
||||||
|
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||||
FavoritesPage(
|
FavoritesPage(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|
@ -235,6 +250,7 @@ fun DestinationContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
Destination.Search -> {
|
Destination.Search -> {
|
||||||
|
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||||
SearchPage(
|
SearchPage(
|
||||||
userPreferences = preferences,
|
userPreferences = preferences,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
import com.github.damontecres.wholphin.preferences.AppThemeColors
|
import com.github.damontecres.wholphin.preferences.AppThemeColors
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||||
import com.github.damontecres.wholphin.ui.components.TimeDisplay
|
import com.github.damontecres.wholphin.ui.components.TimeDisplay
|
||||||
|
|
@ -105,6 +106,7 @@ class NavDrawerViewModel
|
||||||
constructor(
|
constructor(
|
||||||
private val navDrawerItemRepository: NavDrawerItemRepository,
|
private val navDrawerItemRepository: NavDrawerItemRepository,
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
|
val backdropService: BackdropService,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private var all: List<NavDrawerItem>? = null
|
private var all: List<NavDrawerItem>? = null
|
||||||
val moreLibraries = MutableLiveData<List<NavDrawerItem>>(null)
|
val moreLibraries = MutableLiveData<List<NavDrawerItem>>(null)
|
||||||
|
|
@ -217,6 +219,7 @@ fun NavDrawer(
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
user: JellyfinUser,
|
user: JellyfinUser,
|
||||||
server: JellyfinServer,
|
server: JellyfinServer,
|
||||||
|
onClearBackdrop: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: NavDrawerViewModel =
|
viewModel: NavDrawerViewModel =
|
||||||
hiltViewModel(
|
hiltViewModel(
|
||||||
|
|
@ -314,11 +317,9 @@ fun NavDrawer(
|
||||||
val drawerPadding by animateDpAsState(if (drawerState.isOpen) 0.dp else 8.dp)
|
val drawerPadding by animateDpAsState(if (drawerState.isOpen) 0.dp else 8.dp)
|
||||||
val drawerBackground by animateColorAsState(
|
val drawerBackground by animateColorAsState(
|
||||||
if (drawerState.isOpen) {
|
if (drawerState.isOpen) {
|
||||||
MaterialTheme.colorScheme.surfaceColorAtElevation(
|
|
||||||
1.dp,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.surface
|
MaterialTheme.colorScheme.surface
|
||||||
|
} else {
|
||||||
|
Color.Transparent
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
val spacedBy = 4.dp
|
val spacedBy = 4.dp
|
||||||
|
|
@ -542,6 +543,7 @@ fun NavDrawer(
|
||||||
DestinationContent(
|
DestinationContent(
|
||||||
destination = destination,
|
destination = destination,
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
|
onClearBackdrop = onClearBackdrop,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import com.github.damontecres.wholphin.util.TrackSupport
|
||||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||||
import org.jellyfin.sdk.model.api.PlayMethod
|
import org.jellyfin.sdk.model.api.PlayMethod
|
||||||
import org.jellyfin.sdk.model.api.TranscodingInfo
|
import org.jellyfin.sdk.model.api.TranscodingInfo
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
data class CurrentPlayback(
|
data class CurrentPlayback(
|
||||||
val item: BaseItem,
|
val item: BaseItem,
|
||||||
|
|
@ -18,4 +19,5 @@ data class CurrentPlayback(
|
||||||
val videoDecoder: String? = null,
|
val videoDecoder: String? = null,
|
||||||
val audioDecoder: String? = null,
|
val audioDecoder: String? = null,
|
||||||
val transcodeInfo: TranscodingInfo? = null,
|
val transcodeInfo: TranscodingInfo? = null,
|
||||||
|
val subtitleDelay: Duration = Duration.ZERO,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,18 @@ import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Star
|
import androidx.compose.material.icons.filled.Star
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.drawBehind
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
|
@ -102,7 +106,8 @@ fun DownloadSubtitlesContent(
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
val lang = rememberTextFieldState(language)
|
// val lang = rememberTextFieldState(language)
|
||||||
|
var lang by rememberSaveable { mutableStateOf("") }
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
|
@ -113,10 +118,17 @@ fun DownloadSubtitlesContent(
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
EditTextBox(
|
EditTextBox(
|
||||||
state = lang,
|
value = lang,
|
||||||
onKeyboardAction = {
|
onValueChange = { lang = it },
|
||||||
onSearch(lang.text.toString())
|
keyboardActions =
|
||||||
|
KeyboardActions(
|
||||||
|
onSearch = {
|
||||||
|
onSearch(lang)
|
||||||
},
|
},
|
||||||
|
),
|
||||||
|
// onKeyboardAction = {
|
||||||
|
// onSearch(lang.text.toString())
|
||||||
|
// },
|
||||||
keyboardOptions =
|
keyboardOptions =
|
||||||
KeyboardOptions(
|
KeyboardOptions(
|
||||||
imeAction = ImeAction.Search,
|
imeAction = ImeAction.Search,
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ fun NextUpEpisode(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.aspectRatio(aspectRatio)
|
.aspectRatio(aspectRatio.coerceAtLeast(AspectRatios.MIN))
|
||||||
// .fillMaxSize()
|
// .fillMaxSize()
|
||||||
// .weight(1f)
|
// .weight(1f)
|
||||||
.focusRequester(focusRequester),
|
.focusRequester(focusRequester),
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,26 @@
|
||||||
package com.github.damontecres.wholphin.ui.playback
|
package com.github.damontecres.wholphin.ui.playback
|
||||||
|
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.wrapContentSize
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.compose.ui.window.DialogWindowProvider
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||||
|
import com.github.damontecres.wholphin.ui.AppColors
|
||||||
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
enum class PlaybackDialogType {
|
enum class PlaybackDialogType {
|
||||||
MORE,
|
MORE,
|
||||||
|
|
@ -16,6 +29,7 @@ enum class PlaybackDialogType {
|
||||||
AUDIO,
|
AUDIO,
|
||||||
PLAYBACK_SPEED,
|
PLAYBACK_SPEED,
|
||||||
VIDEO_SCALE,
|
VIDEO_SCALE,
|
||||||
|
SUBTITLE_DELAY,
|
||||||
}
|
}
|
||||||
|
|
||||||
data class PlaybackSettings(
|
data class PlaybackSettings(
|
||||||
|
|
@ -26,16 +40,19 @@ data class PlaybackSettings(
|
||||||
val subtitleStreams: List<SubtitleStream>,
|
val subtitleStreams: List<SubtitleStream>,
|
||||||
val playbackSpeed: Float,
|
val playbackSpeed: Float,
|
||||||
val contentScale: ContentScale,
|
val contentScale: ContentScale,
|
||||||
|
val subtitleDelay: Duration,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PlaybackDialog(
|
fun PlaybackDialog(
|
||||||
|
enableSubtitleDelay: Boolean,
|
||||||
type: PlaybackDialogType,
|
type: PlaybackDialogType,
|
||||||
settings: PlaybackSettings,
|
settings: PlaybackSettings,
|
||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
onControllerInteraction: () -> Unit,
|
onControllerInteraction: () -> Unit,
|
||||||
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
|
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
|
||||||
onPlaybackActionClick: (PlaybackAction) -> Unit,
|
onPlaybackActionClick: (PlaybackAction) -> Unit,
|
||||||
|
onChangeSubtitleDelay: (Duration) -> Unit,
|
||||||
) {
|
) {
|
||||||
when (type) {
|
when (type) {
|
||||||
PlaybackDialogType.MORE -> {
|
PlaybackDialogType.MORE -> {
|
||||||
|
|
@ -97,11 +114,14 @@ fun PlaybackDialog(
|
||||||
|
|
||||||
PlaybackDialogType.SETTINGS -> {
|
PlaybackDialogType.SETTINGS -> {
|
||||||
val options =
|
val options =
|
||||||
listOf(
|
buildList {
|
||||||
stringResource(R.string.audio),
|
add(stringResource(R.string.audio))
|
||||||
stringResource(R.string.playback_speed),
|
add(stringResource(R.string.playback_speed))
|
||||||
stringResource(R.string.video_scale),
|
add(stringResource(R.string.video_scale))
|
||||||
)
|
if (enableSubtitleDelay) {
|
||||||
|
add(stringResource(R.string.subtitle_delay))
|
||||||
|
}
|
||||||
|
}
|
||||||
BottomDialog(
|
BottomDialog(
|
||||||
choices = options,
|
choices = options,
|
||||||
currentChoice = null,
|
currentChoice = null,
|
||||||
|
|
@ -111,6 +131,7 @@ fun PlaybackDialog(
|
||||||
0 -> onClickPlaybackDialogType(PlaybackDialogType.AUDIO)
|
0 -> onClickPlaybackDialogType(PlaybackDialogType.AUDIO)
|
||||||
1 -> onClickPlaybackDialogType(PlaybackDialogType.PLAYBACK_SPEED)
|
1 -> onClickPlaybackDialogType(PlaybackDialogType.PLAYBACK_SPEED)
|
||||||
2 -> onClickPlaybackDialogType(PlaybackDialogType.VIDEO_SCALE)
|
2 -> onClickPlaybackDialogType(PlaybackDialogType.VIDEO_SCALE)
|
||||||
|
3 -> onClickPlaybackDialogType(PlaybackDialogType.SUBTITLE_DELAY)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
gravity = Gravity.END,
|
gravity = Gravity.END,
|
||||||
|
|
@ -173,5 +194,31 @@ fun PlaybackDialog(
|
||||||
gravity = Gravity.END,
|
gravity = Gravity.END,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PlaybackDialogType.SUBTITLE_DELAY -> {
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||||
|
) {
|
||||||
|
val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider
|
||||||
|
dialogWindowProvider?.window?.setDimAmount(0f)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.wrapContentSize()
|
||||||
|
.background(
|
||||||
|
AppColors.TransparentBlack50,
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
SubtitleDelay(
|
||||||
|
delay = settings.subtitleDelay,
|
||||||
|
onChangeDelay = onChangeSubtitleDelay,
|
||||||
|
modifier = Modifier.padding(8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
|
@ -34,6 +35,8 @@ import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.focusRestorer
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.key.KeyEventType
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
import androidx.compose.ui.input.key.type
|
import androidx.compose.ui.input.key.type
|
||||||
|
|
@ -127,10 +130,37 @@ fun PlaybackOverlay(
|
||||||
var controllerHeight by remember { mutableStateOf(0.dp) }
|
var controllerHeight by remember { mutableStateOf(0.dp) }
|
||||||
var state by remember { mutableStateOf(OverlayViewState.CONTROLLER) }
|
var state by remember { mutableStateOf(OverlayViewState.CONTROLLER) }
|
||||||
|
|
||||||
|
// Background scrim for OSD readability
|
||||||
|
val scrimBrush =
|
||||||
|
remember {
|
||||||
|
Brush.verticalGradient(
|
||||||
|
colors =
|
||||||
|
listOf(
|
||||||
|
Color.Transparent,
|
||||||
|
Color.Black.copy(alpha = 0.5f),
|
||||||
|
Color.Black.copy(alpha = 0.80f),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
contentAlignment = Alignment.BottomCenter,
|
contentAlignment = Alignment.BottomCenter,
|
||||||
) {
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = controllerViewState.controlsVisible,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
modifier = Modifier.matchParentSize(),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(scrimBrush),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
state == OverlayViewState.CONTROLLER,
|
state == OverlayViewState.CONTROLLER,
|
||||||
enter = slideInVertically() + fadeIn(),
|
enter = slideInVertically() + fadeIn(),
|
||||||
|
|
@ -398,17 +428,13 @@ fun PlaybackOverlay(
|
||||||
val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight
|
val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight
|
||||||
val index =
|
val index =
|
||||||
(seekProgressMs / trickplayInfo.interval).toInt() / tilesPerImage
|
(seekProgressMs / trickplayInfo.interval).toInt() / tilesPerImage
|
||||||
val imageUrl = trickplayUrlFor(index)
|
val imageUrl = remember(index) { trickplayUrlFor(index) }
|
||||||
|
|
||||||
if (imageUrl != null) {
|
if (imageUrl != null) {
|
||||||
SeekPreviewImage(
|
SeekPreviewImage(
|
||||||
modifier =
|
modifier = Modifier,
|
||||||
Modifier,
|
|
||||||
previewImageUrl = imageUrl,
|
previewImageUrl = imageUrl,
|
||||||
duration = playerControls.duration,
|
|
||||||
seekProgressMs = seekProgressMs,
|
seekProgressMs = seekProgressMs,
|
||||||
videoWidth = trickplayInfo.width,
|
|
||||||
videoHeight = trickplayInfo.height,
|
|
||||||
trickPlayInfo = trickplayInfo,
|
trickPlayInfo = trickplayInfo,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,11 +79,13 @@ import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import com.github.damontecres.wholphin.util.Media3SubtitleOverride
|
import com.github.damontecres.wholphin.util.Media3SubtitleOverride
|
||||||
|
import com.github.damontecres.wholphin.util.mpv.MpvPlayer
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
|
@ -163,6 +165,11 @@ fun PlaybackPage(
|
||||||
var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
|
var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
|
||||||
LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) }
|
LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) }
|
||||||
|
|
||||||
|
val subtitleDelay = currentPlayback?.subtitleDelay ?: Duration.ZERO
|
||||||
|
LaunchedEffect(subtitleDelay) {
|
||||||
|
(player as? MpvPlayer)?.subtitleDelay = subtitleDelay
|
||||||
|
}
|
||||||
|
|
||||||
val presentationState = rememberPresentationState(player, false)
|
val presentationState = rememberPresentationState(player, false)
|
||||||
val scaledModifier =
|
val scaledModifier =
|
||||||
Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp)
|
Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp)
|
||||||
|
|
@ -325,37 +332,6 @@ fun PlaybackPage(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subtitles
|
|
||||||
if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) {
|
|
||||||
val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f)
|
|
||||||
AndroidView(
|
|
||||||
factory = { context ->
|
|
||||||
SubtitleView(context).apply {
|
|
||||||
preferences.appPreferences.interfacePreferences.subtitlesPreferences.let {
|
|
||||||
setStyle(it.toSubtitleStyle())
|
|
||||||
setFixedTextSize(Dimension.SP, it.fontSize.toFloat())
|
|
||||||
setBottomPaddingFraction(it.margin.toFloat() / 100f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
update = {
|
|
||||||
it.setCues(cues)
|
|
||||||
Media3SubtitleOverride(
|
|
||||||
preferences.appPreferences.interfacePreferences.subtitlesPreferences
|
|
||||||
.calculateEdgeSize(density),
|
|
||||||
).apply(it)
|
|
||||||
},
|
|
||||||
onReset = {
|
|
||||||
it.setCues(null)
|
|
||||||
},
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxSize(maxSize)
|
|
||||||
.align(Alignment.TopCenter)
|
|
||||||
.background(Color.Transparent),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The playback controls
|
// The playback controls
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
controllerViewState.controlsVisible,
|
controllerViewState.controlsVisible,
|
||||||
|
|
@ -395,6 +371,37 @@ fun PlaybackPage(
|
||||||
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Subtitles
|
||||||
|
if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) {
|
||||||
|
val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f)
|
||||||
|
AndroidView(
|
||||||
|
factory = { context ->
|
||||||
|
SubtitleView(context).apply {
|
||||||
|
preferences.appPreferences.interfacePreferences.subtitlesPreferences.let {
|
||||||
|
setStyle(it.toSubtitleStyle())
|
||||||
|
setFixedTextSize(Dimension.SP, it.fontSize.toFloat())
|
||||||
|
setBottomPaddingFraction(it.margin.toFloat() / 100f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update = {
|
||||||
|
it.setCues(cues)
|
||||||
|
Media3SubtitleOverride(
|
||||||
|
preferences.appPreferences.interfacePreferences.subtitlesPreferences
|
||||||
|
.calculateEdgeSize(density),
|
||||||
|
).apply(it)
|
||||||
|
},
|
||||||
|
onReset = {
|
||||||
|
it.setCues(null)
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize(maxSize)
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.background(Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ask to skip intros, etc button
|
// Ask to skip intros, etc button
|
||||||
|
|
@ -473,9 +480,7 @@ fun PlaybackPage(
|
||||||
).joinToString(" - "),
|
).joinToString(" - "),
|
||||||
description = it.data.overview,
|
description = it.data.overview,
|
||||||
imageUrl = LocalImageUrlService.current.rememberImageUrl(it),
|
imageUrl = LocalImageUrlService.current.rememberImageUrl(it),
|
||||||
aspectRatio =
|
aspectRatio = it.aspectRatio ?: AspectRatios.WIDE,
|
||||||
it.data.primaryImageAspectRatio?.toFloat()
|
|
||||||
?: AspectRatios.WIDE,
|
|
||||||
onClick = {
|
onClick = {
|
||||||
viewModel.reportInteraction()
|
viewModel.reportInteraction()
|
||||||
viewModel.playNextUp()
|
viewModel.playNextUp()
|
||||||
|
|
@ -545,6 +550,7 @@ fun PlaybackPage(
|
||||||
subtitleStreams = mediaInfo?.subtitleStreams.orEmpty(),
|
subtitleStreams = mediaInfo?.subtitleStreams.orEmpty(),
|
||||||
playbackSpeed = playbackSpeed,
|
playbackSpeed = playbackSpeed,
|
||||||
contentScale = contentScale,
|
contentScale = contentScale,
|
||||||
|
subtitleDelay = subtitleDelay,
|
||||||
),
|
),
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
playbackDialog = null
|
playbackDialog = null
|
||||||
|
|
@ -555,8 +561,16 @@ fun PlaybackPage(
|
||||||
onControllerInteraction = {
|
onControllerInteraction = {
|
||||||
controllerViewState.pulseControls(Long.MAX_VALUE)
|
controllerViewState.pulseControls(Long.MAX_VALUE)
|
||||||
},
|
},
|
||||||
onClickPlaybackDialogType = { playbackDialog = it },
|
onClickPlaybackDialogType = {
|
||||||
|
if (it == PlaybackDialogType.SUBTITLE_DELAY) {
|
||||||
|
// Hide controls so subtitles are fully visible
|
||||||
|
controllerViewState.hideControls()
|
||||||
|
}
|
||||||
|
playbackDialog = it
|
||||||
|
},
|
||||||
onPlaybackActionClick = onPlaybackActionClick,
|
onPlaybackActionClick = onPlaybackActionClick,
|
||||||
|
onChangeSubtitleDelay = { viewModel.updateSubtitleDelay(it) },
|
||||||
|
enableSubtitleDelay = player is MpvPlayer,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ import androidx.media3.exoplayer.DecoderCounters
|
||||||
import androidx.media3.exoplayer.DecoderReuseEvaluation
|
import androidx.media3.exoplayer.DecoderReuseEvaluation
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.exoplayer.analytics.AnalyticsListener
|
import androidx.media3.exoplayer.analytics.AnalyticsListener
|
||||||
|
import coil3.imageLoader
|
||||||
|
import coil3.request.ImageRequest
|
||||||
import com.github.damontecres.wholphin.data.ItemPlaybackDao
|
import com.github.damontecres.wholphin.data.ItemPlaybackDao
|
||||||
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
|
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
|
@ -29,8 +31,6 @@ import com.github.damontecres.wholphin.data.model.Chapter
|
||||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||||
import com.github.damontecres.wholphin.data.model.Playlist
|
import com.github.damontecres.wholphin.data.model.Playlist
|
||||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||||
import com.github.damontecres.wholphin.data.model.chooseSource
|
|
||||||
import com.github.damontecres.wholphin.data.model.chooseStream
|
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||||
import com.github.damontecres.wholphin.preferences.ShowNextUpWhen
|
import com.github.damontecres.wholphin.preferences.ShowNextUpWhen
|
||||||
|
|
@ -43,6 +43,7 @@ import com.github.damontecres.wholphin.services.PlayerFactory
|
||||||
import com.github.damontecres.wholphin.services.PlaylistCreationResult
|
import com.github.damontecres.wholphin.services.PlaylistCreationResult
|
||||||
import com.github.damontecres.wholphin.services.PlaylistCreator
|
import com.github.damontecres.wholphin.services.PlaylistCreator
|
||||||
import com.github.damontecres.wholphin.services.RefreshRateService
|
import com.github.damontecres.wholphin.services.RefreshRateService
|
||||||
|
import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.onMain
|
import com.github.damontecres.wholphin.ui.onMain
|
||||||
|
|
@ -93,6 +94,7 @@ import org.jellyfin.sdk.model.api.PlayMethod
|
||||||
import org.jellyfin.sdk.model.api.PlaybackInfoDto
|
import org.jellyfin.sdk.model.api.PlaybackInfoDto
|
||||||
import org.jellyfin.sdk.model.api.PlaystateCommand
|
import org.jellyfin.sdk.model.api.PlaystateCommand
|
||||||
import org.jellyfin.sdk.model.api.PlaystateMessage
|
import org.jellyfin.sdk.model.api.PlaystateMessage
|
||||||
|
import org.jellyfin.sdk.model.api.TrickplayInfo
|
||||||
import org.jellyfin.sdk.model.extensions.inWholeTicks
|
import org.jellyfin.sdk.model.extensions.inWholeTicks
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
|
|
@ -100,6 +102,7 @@ import timber.log.Timber
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
|
@ -123,6 +126,7 @@ class PlaybackViewModel
|
||||||
private val deviceInfo: DeviceInfo,
|
private val deviceInfo: DeviceInfo,
|
||||||
private val deviceProfileService: DeviceProfileService,
|
private val deviceProfileService: DeviceProfileService,
|
||||||
private val refreshRateService: RefreshRateService,
|
private val refreshRateService: RefreshRateService,
|
||||||
|
val streamChoiceService: StreamChoiceService,
|
||||||
) : ViewModel(),
|
) : ViewModel(),
|
||||||
Player.Listener,
|
Player.Listener,
|
||||||
AnalyticsListener {
|
AnalyticsListener {
|
||||||
|
|
@ -225,9 +229,13 @@ class PlaybackViewModel
|
||||||
"Error preparing for playback for $itemId",
|
"Error preparing for playback for $itemId",
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
|
val destItem = (destination as? Destination.Playback)?.item?.data
|
||||||
val queriedItem =
|
val queriedItem =
|
||||||
(destination as? Destination.Playback)?.item?.data
|
if (destItem?.mediaSources != null) {
|
||||||
?: api.userLibraryApi.getItem(itemId).content
|
destItem
|
||||||
|
} else {
|
||||||
|
api.userLibraryApi.getItem(itemId).content
|
||||||
|
}
|
||||||
val base =
|
val base =
|
||||||
if (queriedItem.type.playable) {
|
if (queriedItem.type.playable) {
|
||||||
queriedItem
|
queriedItem
|
||||||
|
|
@ -338,7 +346,8 @@ class PlaybackViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val mediaSource = chooseSource(base, playbackConfig)
|
val mediaSource = streamChoiceService.chooseSource(base, playbackConfig)
|
||||||
|
val plc = streamChoiceService.getPlaybackLanguageChoice(base)
|
||||||
|
|
||||||
if (mediaSource == null) {
|
if (mediaSource == null) {
|
||||||
showToast(
|
showToast(
|
||||||
|
|
@ -361,13 +370,27 @@ class PlaybackViewModel
|
||||||
?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels })
|
?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels })
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
|
|
||||||
val audioIndex =
|
val audioStream =
|
||||||
chooseStream(base, playbackConfig, MediaStreamType.AUDIO, preferences)
|
streamChoiceService
|
||||||
?.index
|
.chooseAudioStream(
|
||||||
|
source = mediaSource,
|
||||||
|
seriesId = base.seriesId,
|
||||||
|
itemPlayback = playbackConfig,
|
||||||
|
plc = plc,
|
||||||
|
prefs = preferences,
|
||||||
|
)
|
||||||
|
val audioIndex = audioStream?.index
|
||||||
|
|
||||||
val subtitleIndex =
|
val subtitleIndex =
|
||||||
chooseStream(base, playbackConfig, MediaStreamType.SUBTITLE, preferences)
|
streamChoiceService
|
||||||
?.index
|
.chooseSubtitleStream(
|
||||||
|
source = mediaSource,
|
||||||
|
audioStream = audioStream,
|
||||||
|
seriesId = base.seriesId,
|
||||||
|
itemPlayback = playbackConfig,
|
||||||
|
plc = plc,
|
||||||
|
prefs = preferences,
|
||||||
|
)?.index
|
||||||
|
|
||||||
Timber.d("Selected mediaSource=${mediaSource.id}, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex")
|
Timber.d("Selected mediaSource=${mediaSource.id}, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex")
|
||||||
|
|
||||||
|
|
@ -385,6 +408,17 @@ class PlaybackViewModel
|
||||||
?.get(mediaSource.id)
|
?.get(mediaSource.id)
|
||||||
?.values
|
?.values
|
||||||
?.firstOrNull()
|
?.firstOrNull()
|
||||||
|
trickPlayInfo?.let { trickplayInfo ->
|
||||||
|
mediaSource.runTimeTicks?.ticks?.let { duration ->
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
prefetchTrickplay(
|
||||||
|
duration,
|
||||||
|
trickplayInfo,
|
||||||
|
mediaSource.id?.toUUIDOrNull(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val chapters = Chapter.fromDto(base, api)
|
val chapters = Chapter.fromDto(base, api)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
|
|
@ -481,7 +515,7 @@ class PlaybackViewModel
|
||||||
|
|
||||||
this@PlaybackViewModel.currentItemPlayback.value = itemPlayback
|
this@PlaybackViewModel.currentItemPlayback.value = itemPlayback
|
||||||
}
|
}
|
||||||
|
loadSubtitleDelay()
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -563,7 +597,7 @@ class PlaybackViewModel
|
||||||
|
|
||||||
else -> throw Exception("No supported playback method")
|
else -> throw Exception("No supported playback method")
|
||||||
}
|
}
|
||||||
Timber.v("Playback decision: $transcodeType")
|
Timber.i("Playback decision for $itemId: $transcodeType")
|
||||||
|
|
||||||
val externalSubtitleCount = source.externalSubtitlesCount
|
val externalSubtitleCount = source.externalSubtitlesCount
|
||||||
|
|
||||||
|
|
@ -605,21 +639,6 @@ class PlaybackViewModel
|
||||||
liveStreamId = source.liveStreamId,
|
liveStreamId = source.liveStreamId,
|
||||||
mediaSourceInfo = source,
|
mediaSourceInfo = source,
|
||||||
)
|
)
|
||||||
val itemPlayback =
|
|
||||||
currentItemPlayback.copy(
|
|
||||||
sourceId = source.id?.toUUIDOrNull(),
|
|
||||||
audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED,
|
|
||||||
subtitleIndex = subtitleIndex ?: TrackIndex.DISABLED,
|
|
||||||
)
|
|
||||||
if (userInitiated) {
|
|
||||||
viewModelScope.launchIO {
|
|
||||||
Timber.v("Saving user initiated item playback: %s", itemPlayback)
|
|
||||||
val updated = itemPlaybackRepository.saveItemPlayback(itemPlayback)
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
this@PlaybackViewModel.currentItemPlayback.value = updated
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) {
|
if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) {
|
||||||
source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }?.let {
|
source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }?.let {
|
||||||
|
|
@ -638,14 +657,13 @@ class PlaybackViewModel
|
||||||
api = api,
|
api = api,
|
||||||
player = player,
|
player = player,
|
||||||
playback = playback,
|
playback = playback,
|
||||||
itemPlayback = itemPlayback,
|
itemPlayback = currentItemPlayback,
|
||||||
)
|
)
|
||||||
player.addListener(activityListener)
|
player.addListener(activityListener)
|
||||||
this@PlaybackViewModel.activityListener = activityListener
|
this@PlaybackViewModel.activityListener = activityListener
|
||||||
|
|
||||||
loading.value = LoadingState.Success
|
loading.value = LoadingState.Success
|
||||||
this@PlaybackViewModel.currentPlayback.update { playback }
|
this@PlaybackViewModel.currentPlayback.update { playback }
|
||||||
this@PlaybackViewModel.currentItemPlayback.value = itemPlayback
|
|
||||||
player.setMediaItem(
|
player.setMediaItem(
|
||||||
mediaItem,
|
mediaItem,
|
||||||
positionMs,
|
positionMs,
|
||||||
|
|
@ -668,6 +686,7 @@ class PlaybackViewModel
|
||||||
if (result.bothSelected) {
|
if (result.bothSelected) {
|
||||||
player.removeListener(this)
|
player.removeListener(this)
|
||||||
}
|
}
|
||||||
|
viewModelScope.launchIO { loadSubtitleDelay() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -679,33 +698,74 @@ class PlaybackViewModel
|
||||||
|
|
||||||
fun changeAudioStream(index: Int) {
|
fun changeAudioStream(index: Int) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
|
Timber.d("Changing audio track to %s", index)
|
||||||
|
val itemPlayback =
|
||||||
|
itemPlaybackRepository.saveTrackSelection(
|
||||||
|
item = item,
|
||||||
|
itemPlayback = currentItemPlayback.value!!,
|
||||||
|
trackIndex = index,
|
||||||
|
type = MediaStreamType.AUDIO,
|
||||||
|
)
|
||||||
|
this@PlaybackViewModel.currentItemPlayback.setValueOnMain(itemPlayback)
|
||||||
changeStreams(
|
changeStreams(
|
||||||
item,
|
item,
|
||||||
currentItemPlayback.value!!,
|
itemPlayback,
|
||||||
index,
|
index,
|
||||||
currentItemPlayback.value?.subtitleIndex,
|
itemPlayback.subtitleIndex,
|
||||||
onMain { player.currentPosition },
|
onMain { player.currentPosition },
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun changeSubtitleStream(index: Int?): Job =
|
fun changeSubtitleStream(index: Int): Job =
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
|
Timber.d("Changing subtitle track to %s", index)
|
||||||
|
val itemPlayback =
|
||||||
|
itemPlaybackRepository.saveTrackSelection(
|
||||||
|
item = item,
|
||||||
|
itemPlayback = currentItemPlayback.value!!,
|
||||||
|
trackIndex = index,
|
||||||
|
type = MediaStreamType.SUBTITLE,
|
||||||
|
)
|
||||||
|
this@PlaybackViewModel.currentItemPlayback.setValueOnMain(itemPlayback)
|
||||||
changeStreams(
|
changeStreams(
|
||||||
item,
|
item,
|
||||||
currentItemPlayback.value!!,
|
itemPlayback,
|
||||||
currentItemPlayback.value?.audioIndex,
|
itemPlayback.audioIndex,
|
||||||
index,
|
index,
|
||||||
onMain { player.currentPosition },
|
onMain { player.currentPosition },
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getTrickplayUrl(index: Int): String? {
|
private suspend fun prefetchTrickplay(
|
||||||
|
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
|
val itemId = item.id
|
||||||
val mediaSourceId = currentItemPlayback.value?.sourceId
|
|
||||||
val trickPlayInfo = currentMediaInfo.value?.trickPlayInfo ?: return null
|
|
||||||
return api.trickplayApi.getTrickplayTileImageUrl(
|
return api.trickplayApi.getTrickplayTileImageUrl(
|
||||||
itemId,
|
itemId,
|
||||||
trickPlayInfo.width,
|
trickPlayInfo.width,
|
||||||
|
|
@ -722,7 +782,7 @@ class PlaybackViewModel
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
nextUp.value = nextItem
|
nextUp.value = nextItem
|
||||||
if (nextItem == null) {
|
if (nextItem == null) {
|
||||||
controllerViewState.showControls()
|
navigationManager.goBack()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1091,4 +1151,47 @@ class PlaybackViewModel
|
||||||
Timber.d("decoder: onAudioDisabled")
|
Timber.d("decoder: onAudioDisabled")
|
||||||
currentPlayback.update { it?.copy(audioDecoder = null) }
|
currentPlayback.update { it?.copy(audioDecoder = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var subtitleDelaySaveJob: Job? = null
|
||||||
|
|
||||||
|
fun updateSubtitleDelay(delta: Duration) {
|
||||||
|
subtitleDelaySaveJob?.cancel()
|
||||||
|
currentPlayback.update {
|
||||||
|
it?.let {
|
||||||
|
val newDelay = it.subtitleDelay + delta
|
||||||
|
val result = it.copy(subtitleDelay = it.subtitleDelay + delta)
|
||||||
|
subtitleDelaySaveJob =
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
// Debounce & save
|
||||||
|
currentItemPlayback.value?.let { item ->
|
||||||
|
delay(1500)
|
||||||
|
itemPlaybackRepository.saveTrackModifications(
|
||||||
|
item.itemId,
|
||||||
|
item.subtitleIndex,
|
||||||
|
newDelay,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun loadSubtitleDelay() {
|
||||||
|
currentItemPlayback.value?.let {
|
||||||
|
if (it.subtitleIndexEnabled) {
|
||||||
|
val result =
|
||||||
|
itemPlaybackRepository.getTrackModifications(it.itemId, it.subtitleIndex)
|
||||||
|
if (result != null) {
|
||||||
|
Timber.v(
|
||||||
|
"Loading subtitle delay %s for track=%s, itemId=%s",
|
||||||
|
result.delayMs,
|
||||||
|
it.subtitleIndex,
|
||||||
|
it.itemId,
|
||||||
|
)
|
||||||
|
currentPlayback.update { it?.copy(subtitleDelay = result.delayMs.milliseconds) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,29 @@
|
||||||
package com.github.damontecres.wholphin.ui.playback
|
package com.github.damontecres.wholphin.ui.playback
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.painter.Painter
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
|
import androidx.compose.ui.graphics.drawscope.scale
|
||||||
|
import androidx.compose.ui.graphics.drawscope.translate
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.layout.layout
|
import androidx.compose.ui.layout.layout
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.rememberAsyncImagePainter
|
||||||
import coil3.request.CachePolicy
|
|
||||||
import coil3.request.ImageRequest
|
import coil3.request.ImageRequest
|
||||||
import coil3.request.transformations
|
|
||||||
import com.github.damontecres.wholphin.ui.CoilTrickplayTransformation
|
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import org.jellyfin.sdk.model.api.TrickplayInfo
|
import org.jellyfin.sdk.model.api.TrickplayInfo
|
||||||
|
|
||||||
|
|
@ -70,54 +75,70 @@ fun Modifier.offsetByPercent(xPercentage: Float) =
|
||||||
@Composable
|
@Composable
|
||||||
fun SeekPreviewImage(
|
fun SeekPreviewImage(
|
||||||
previewImageUrl: String,
|
previewImageUrl: String,
|
||||||
duration: Long,
|
|
||||||
seekProgressMs: Long,
|
seekProgressMs: Long,
|
||||||
videoWidth: Int?,
|
|
||||||
videoHeight: Int?,
|
|
||||||
trickPlayInfo: TrickplayInfo,
|
trickPlayInfo: TrickplayInfo,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
placeHolder: Painter? = null,
|
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
if (previewImageUrl.isNotNullOrBlank() &&
|
if (previewImageUrl.isNotNullOrBlank()) {
|
||||||
videoWidth != null &&
|
|
||||||
videoHeight != null
|
|
||||||
) {
|
|
||||||
val height = 160.dp
|
val height = 160.dp
|
||||||
val width = height * (videoWidth.toFloat() / videoHeight)
|
val width = height * (trickPlayInfo.width.toFloat() / trickPlayInfo.height)
|
||||||
val heightPx = with(LocalDensity.current) { height.toPx().toInt() }
|
val scale = with(LocalDensity.current) { width.toPx() / trickPlayInfo.width }
|
||||||
val widthPx = with(LocalDensity.current) { width.toPx().toInt() }
|
|
||||||
|
|
||||||
val index = (seekProgressMs.toDouble() / trickPlayInfo.interval).toInt() // Which tile
|
val model =
|
||||||
val numberOfTitlesPerImage = trickPlayInfo.tileHeight * trickPlayInfo.tileWidth
|
remember(previewImageUrl) {
|
||||||
val imageIndex = index % numberOfTitlesPerImage
|
|
||||||
|
|
||||||
AsyncImage(
|
|
||||||
modifier =
|
|
||||||
modifier
|
|
||||||
.width(width)
|
|
||||||
.height(height)
|
|
||||||
.background(Color.Black)
|
|
||||||
.border(1.5.dp, color = MaterialTheme.colorScheme.border),
|
|
||||||
model =
|
|
||||||
ImageRequest
|
ImageRequest
|
||||||
.Builder(context)
|
.Builder(context)
|
||||||
.data(previewImageUrl)
|
.data(previewImageUrl)
|
||||||
.memoryCachePolicy(CachePolicy.ENABLED)
|
.size(coil3.size.Size.ORIGINAL)
|
||||||
.transformations(
|
.build()
|
||||||
CoilTrickplayTransformation(
|
}
|
||||||
widthPx,
|
val painter =
|
||||||
heightPx,
|
rememberAsyncImagePainter(
|
||||||
trickPlayInfo.tileHeight,
|
model = model,
|
||||||
trickPlayInfo.tileWidth,
|
|
||||||
imageIndex,
|
|
||||||
index,
|
|
||||||
),
|
|
||||||
).build(),
|
|
||||||
contentScale = ContentScale.None,
|
contentScale = ContentScale.None,
|
||||||
contentDescription = null,
|
)
|
||||||
placeholder = placeHolder,
|
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),
|
modifier = Modifier.align(Alignment.Center),
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
text = abs(durationMs / 1000).toString(),
|
text = abs(durationMs / 1000).toString(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.R
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||||
import com.github.damontecres.wholphin.data.model.chooseSource
|
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.onMain
|
import com.github.damontecres.wholphin.ui.onMain
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||||
|
|
@ -101,7 +100,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
|
||||||
api.userLibraryApi.getItem(itemId = it.itemId).content,
|
api.userLibraryApi.getItem(itemId = it.itemId).content,
|
||||||
api,
|
api,
|
||||||
)
|
)
|
||||||
val mediaSource = chooseSource(item.data, it)
|
val mediaSource = streamChoiceService.chooseSource(item.data, it)
|
||||||
if (mediaSource == null) {
|
if (mediaSource == null) {
|
||||||
// This shouldn't happen, but just in case
|
// This shouldn't happen, but just in case
|
||||||
showToast(
|
showToast(
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import com.github.damontecres.wholphin.data.model.NavPinType
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.resetSubtitles
|
import com.github.damontecres.wholphin.preferences.resetSubtitles
|
||||||
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
|
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
|
||||||
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs
|
import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
|
|
@ -37,6 +38,7 @@ class PreferencesViewModel
|
||||||
private val api: ApiClient,
|
private val api: ApiClient,
|
||||||
val preferenceDataStore: DataStore<AppPreferences>,
|
val preferenceDataStore: DataStore<AppPreferences>,
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
|
val backdropService: BackdropService,
|
||||||
private val rememberTabManager: RememberTabManager,
|
private val rememberTabManager: RememberTabManager,
|
||||||
private val serverRepository: ServerRepository,
|
private val serverRepository: ServerRepository,
|
||||||
private val navDrawerItemRepository: NavDrawerItemRepository,
|
private val navDrawerItemRepository: NavDrawerItemRepository,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
|
@ -38,9 +39,14 @@ fun StringInputDialog(
|
||||||
elevation: Dp = 3.dp,
|
elevation: Dp = 3.dp,
|
||||||
) {
|
) {
|
||||||
val mutableValue = rememberTextFieldState(input.value ?: "")
|
val mutableValue = rememberTextFieldState(input.value ?: "")
|
||||||
|
var mutableText by remember { mutableStateOf(input.value ?: "") }
|
||||||
// var mutableValue by remember { mutableStateOf(input.value ?: "") }
|
// var mutableValue by remember { mutableStateOf(input.value ?: "") }
|
||||||
val onDone = {
|
val onDone = {
|
||||||
|
if (input.maxLines > 1) {
|
||||||
onSave.invoke(mutableValue.text.toString())
|
onSave.invoke(mutableValue.text.toString())
|
||||||
|
} else {
|
||||||
|
onSave.invoke(mutableText)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var showConfirm by remember { mutableStateOf(false) }
|
var showConfirm by remember { mutableStateOf(false) }
|
||||||
Dialog(
|
Dialog(
|
||||||
|
|
@ -79,6 +85,7 @@ fun StringInputDialog(
|
||||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
)
|
)
|
||||||
|
if (input.maxLines > 1) {
|
||||||
EditTextBox(
|
EditTextBox(
|
||||||
state = mutableValue,
|
state = mutableValue,
|
||||||
keyboardOptions = input.keyboardOptions,
|
keyboardOptions = input.keyboardOptions,
|
||||||
|
|
@ -90,6 +97,22 @@ fun StringInputDialog(
|
||||||
maxLines = input.maxLines,
|
maxLines = input.maxLines,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
EditTextBox(
|
||||||
|
value = mutableText,
|
||||||
|
onValueChange = { mutableText = it },
|
||||||
|
keyboardOptions = input.keyboardOptions,
|
||||||
|
keyboardActions =
|
||||||
|
KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
onDone.invoke()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
leadingIcon = null,
|
||||||
|
isInputValid = { true },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.SpaceAround,
|
horizontalArrangement = Arrangement.SpaceAround,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,17 @@
|
||||||
package com.github.damontecres.wholphin.ui.setup
|
package com.github.damontecres.wholphin.ui.setup
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
|
@ -13,18 +22,26 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.tv.material3.Border
|
||||||
|
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||||
import androidx.tv.material3.Icon
|
import androidx.tv.material3.Icon
|
||||||
import androidx.tv.material3.IconButtonDefaults
|
import androidx.tv.material3.IconButtonDefaults
|
||||||
import androidx.tv.material3.ListItem
|
import androidx.tv.material3.ListItem
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Surface
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
|
import com.github.damontecres.wholphin.ui.Cards
|
||||||
import com.github.damontecres.wholphin.ui.components.CircularProgress
|
import com.github.damontecres.wholphin.ui.components.CircularProgress
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogItem
|
import com.github.damontecres.wholphin.ui.components.DialogItem
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||||
|
|
@ -157,3 +174,261 @@ fun ServerList(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a consistent color for a UUID
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberIdColor(
|
||||||
|
id: UUID?,
|
||||||
|
nullColor: Color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
): Color =
|
||||||
|
remember(id) {
|
||||||
|
if (id == null) {
|
||||||
|
return@remember nullColor
|
||||||
|
}
|
||||||
|
// Generate a color based on the server ID hash, fallback to URL hash
|
||||||
|
val hash = id.hashCode()
|
||||||
|
val hue = (hash % 360).toFloat()
|
||||||
|
val saturation = 0.6f + ((hash / 360) % 40).toFloat() / 100f // 0.6-1.0
|
||||||
|
val brightness = 0.4f + ((hash / 14400) % 30).toFloat() / 100f // 0.4-0.7 (darker colors)
|
||||||
|
|
||||||
|
// Convert HSV to RGB
|
||||||
|
val c = brightness * saturation
|
||||||
|
val x = c * (1 - kotlin.math.abs((hue / 60f) % 2f - 1))
|
||||||
|
val m = brightness - c
|
||||||
|
|
||||||
|
val (r, g, b) =
|
||||||
|
when {
|
||||||
|
hue < 60 -> Triple(c, x, 0f)
|
||||||
|
hue < 120 -> Triple(x, c, 0f)
|
||||||
|
hue < 180 -> Triple(0f, c, x)
|
||||||
|
hue < 240 -> Triple(0f, x, c)
|
||||||
|
hue < 300 -> Triple(x, 0f, c)
|
||||||
|
else -> Triple(c, 0f, x)
|
||||||
|
}
|
||||||
|
|
||||||
|
Color(
|
||||||
|
red = (r + m).coerceIn(0f, 1f),
|
||||||
|
green = (g + m).coerceIn(0f, 1f),
|
||||||
|
blue = (b + m).coerceIn(0f, 1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server icon card component - displays a circular card with server name/letter
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ServerIconCard(
|
||||||
|
server: JellyfinServer,
|
||||||
|
connectionStatus: ServerConnectionStatus,
|
||||||
|
isCurrentServer: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
allowDelete: Boolean = false,
|
||||||
|
) {
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
|
||||||
|
// Generate unique color for this server
|
||||||
|
val serverColor = rememberIdColor(server.id)
|
||||||
|
|
||||||
|
// Card dimensions - circular card
|
||||||
|
val cardSize = Cards.serverUserCircle
|
||||||
|
|
||||||
|
val displayText =
|
||||||
|
remember(server) {
|
||||||
|
(server.name ?: server.url.replace(Regex("^https?://"), ""))
|
||||||
|
.firstOrNull()
|
||||||
|
?.uppercase()
|
||||||
|
?: "?"
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
|
) {
|
||||||
|
// Circular card with colored background
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = if (allowDelete) onLongClick else null,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
modifier = Modifier.size(cardSize),
|
||||||
|
shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
|
||||||
|
colors =
|
||||||
|
ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor =
|
||||||
|
if (isCurrentServer) {
|
||||||
|
serverColor.copy(alpha = 0.7f)
|
||||||
|
} else {
|
||||||
|
serverColor.copy(alpha = 0.5f)
|
||||||
|
},
|
||||||
|
focusedContainerColor =
|
||||||
|
if (isCurrentServer) {
|
||||||
|
serverColor.copy(alpha = 0.9f)
|
||||||
|
} else {
|
||||||
|
serverColor.copy(alpha = 0.7f)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
border =
|
||||||
|
ClickableSurfaceDefaults.border(
|
||||||
|
focusedBorder =
|
||||||
|
Border(
|
||||||
|
border =
|
||||||
|
BorderStroke(
|
||||||
|
width = 3.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
shape = CircleShape,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.2f),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
// Show connection status indicator or server name/letter
|
||||||
|
when (connectionStatus) {
|
||||||
|
is ServerConnectionStatus.Success -> {
|
||||||
|
// Show server name/letter
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = displayText,
|
||||||
|
style =
|
||||||
|
MaterialTheme.typography.displayLarge.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ServerConnectionStatus.Pending -> {
|
||||||
|
CircularProgress(
|
||||||
|
modifier = Modifier.size(cardSize * 0.4f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is ServerConnectionStatus.Error -> {
|
||||||
|
// Show warning icon with server letter below
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Warning,
|
||||||
|
contentDescription = connectionStatus.message,
|
||||||
|
tint = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
modifier = Modifier.size(cardSize * 0.3f),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = displayText,
|
||||||
|
style =
|
||||||
|
MaterialTheme.typography.titleLarge.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server name below the card
|
||||||
|
Text(
|
||||||
|
text = server.name?.ifBlank { null } ?: server.url,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.width(cardSize)
|
||||||
|
.padding(horizontal = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add Server card component - displays a + icon in a circle
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AddServerCard(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
|
||||||
|
// Use a neutral gray color for the add server card
|
||||||
|
val addServerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
|
||||||
|
// Card dimensions - circular card (same as server cards)
|
||||||
|
val cardSize = Cards.height2x3 * 0.75f // ~120dp
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
|
) {
|
||||||
|
// Circular card with colored background
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
modifier = Modifier.size(cardSize),
|
||||||
|
shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
|
||||||
|
colors =
|
||||||
|
ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = addServerColor.copy(alpha = 0.4f),
|
||||||
|
focusedContainerColor = addServerColor.copy(alpha = 0.6f),
|
||||||
|
),
|
||||||
|
border =
|
||||||
|
ClickableSurfaceDefaults.border(
|
||||||
|
focusedBorder =
|
||||||
|
Border(
|
||||||
|
border =
|
||||||
|
BorderStroke(
|
||||||
|
width = 3.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
shape = CircleShape,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.2f),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Add,
|
||||||
|
contentDescription = stringResource(R.string.add_server),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.size(cardSize * 0.4f), // Size of the + icon
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Add Server" text below the card
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.add_server),
|
||||||
|
style =
|
||||||
|
MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.width(cardSize)
|
||||||
|
.padding(horizontal = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,23 @@
|
||||||
package com.github.damontecres.wholphin.ui.setup
|
package com.github.damontecres.wholphin.ui.setup
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.layout.wrapContentWidth
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
|
@ -22,6 +29,8 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
|
@ -29,19 +38,21 @@ import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.tv.material3.ListItem
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import androidx.tv.material3.surfaceColorAtElevation
|
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.ui.components.BasicDialog
|
import com.github.damontecres.wholphin.ui.components.BasicDialog
|
||||||
import com.github.damontecres.wholphin.ui.components.CircularProgress
|
import com.github.damontecres.wholphin.ui.components.CircularProgress
|
||||||
|
import com.github.damontecres.wholphin.ui.components.DialogItem
|
||||||
|
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||||
import com.github.damontecres.wholphin.ui.components.EditTextBox
|
import com.github.damontecres.wholphin.ui.components.EditTextBox
|
||||||
import com.github.damontecres.wholphin.ui.components.TextButton
|
import com.github.damontecres.wholphin.ui.components.TextButton
|
||||||
import com.github.damontecres.wholphin.ui.dimAndBlur
|
import com.github.damontecres.wholphin.ui.dimAndBlur
|
||||||
|
import com.github.damontecres.wholphin.ui.ifElse
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import org.jellyfin.sdk.model.api.PublicSystemInfo
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SwitchServerContent(
|
fun SwitchServerContent(
|
||||||
|
|
@ -51,141 +62,185 @@ fun SwitchServerContent(
|
||||||
val servers by viewModel.servers.observeAsState(listOf())
|
val servers by viewModel.servers.observeAsState(listOf())
|
||||||
val serverStatus by viewModel.serverStatus.observeAsState(mapOf())
|
val serverStatus by viewModel.serverStatus.observeAsState(mapOf())
|
||||||
|
|
||||||
val discoveredServers by viewModel.discoveredServers.observeAsState(listOf())
|
|
||||||
|
|
||||||
var showAddServer by remember { mutableStateOf(false) }
|
var showAddServer by remember { mutableStateOf(false) }
|
||||||
|
var showDeleteDialog by remember { mutableStateOf<com.github.damontecres.wholphin.data.model.JellyfinServer?>(null) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.init()
|
viewModel.init()
|
||||||
viewModel.discoverServers()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier.dimAndBlur(showAddServer),
|
modifier = modifier.dimAndBlur(showAddServer || showDeleteDialog != null),
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.align(Alignment.Center)
|
|
||||||
.padding(32.dp),
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.weight(1f)
|
// Center the content like the Select User screen
|
||||||
.padding(16.dp)
|
.align(Alignment.Center)
|
||||||
.background(
|
.padding(16.dp),
|
||||||
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
|
) {
|
||||||
shape = RoundedCornerShape(16.dp),
|
// Match SwitchUser header height (title + subtitle) to align icons vertically across screens
|
||||||
),
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.select_server),
|
text = stringResource(R.string.select_server),
|
||||||
style = MaterialTheme.typography.displaySmall,
|
style = MaterialTheme.typography.displaySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
ServerList(
|
// Invisible subtitle placeholder to mirror the server name line on the Select User screen
|
||||||
servers = servers,
|
Text(
|
||||||
connectionStatus = serverStatus,
|
text = "Server placeholder",
|
||||||
onSwitchServer = {
|
style = MaterialTheme.typography.titleLarge,
|
||||||
viewModel.switchServer(it)
|
color = Color.Transparent,
|
||||||
},
|
|
||||||
onTestServer = {
|
|
||||||
viewModel.testServer(it)
|
|
||||||
},
|
|
||||||
onAddServer = {
|
|
||||||
showAddServer = true
|
|
||||||
},
|
|
||||||
onRemoveServer = {
|
|
||||||
viewModel.removeServer(it)
|
|
||||||
},
|
|
||||||
allowAdd = true,
|
|
||||||
allowDelete = true,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
|
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Discover
|
|
||||||
Column(
|
// Horizontal scrollable list of server icons - centered
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
Box(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
modifier =
|
contentAlignment = Alignment.Center,
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.weight(1f)
|
|
||||||
.padding(16.dp)
|
|
||||||
.background(
|
|
||||||
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
|
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
),
|
|
||||||
) {
|
) {
|
||||||
Text(
|
val focusRequester = remember { FocusRequester() }
|
||||||
text = stringResource(R.string.discovered_servers),
|
val firstServerFocus = remember { FocusRequester() }
|
||||||
style = MaterialTheme.typography.displaySmall,
|
if (servers.isNotEmpty()) {
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||||
)
|
}
|
||||||
if (discoveredServers.isEmpty()) {
|
LazyRow(
|
||||||
Text(
|
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
text = stringResource(R.string.searching),
|
contentPadding = PaddingValues(horizontal = 48.dp, vertical = 16.dp),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
modifier =
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
Modifier
|
||||||
)
|
.wrapContentWidth()
|
||||||
} else {
|
.focusRestorer(firstServerFocus)
|
||||||
ServerList(
|
.focusRequester(focusRequester),
|
||||||
servers = discoveredServers,
|
) {
|
||||||
connectionStatus =
|
itemsIndexed(servers) { index, server ->
|
||||||
discoveredServers
|
val status = serverStatus[server.id] ?: ServerConnectionStatus.Pending
|
||||||
.map { it.id }
|
ServerIconCard(
|
||||||
.associateWith { ServerConnectionStatus.Success(PublicSystemInfo()) },
|
server = server,
|
||||||
onSwitchServer = {
|
connectionStatus = status,
|
||||||
viewModel.addServer(it.url)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onTestServer = {
|
onLongClick = {
|
||||||
viewModel.testServer(it)
|
showDeleteDialog = server
|
||||||
},
|
},
|
||||||
onAddServer = {},
|
allowDelete = true,
|
||||||
onRemoveServer = {},
|
modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(firstServerFocus)),
|
||||||
allowAdd = false,
|
)
|
||||||
allowDelete = false,
|
}
|
||||||
|
// Add Server card - always rightmost
|
||||||
|
item {
|
||||||
|
AddServerCard(
|
||||||
|
onClick = { showAddServer = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Non-focusable spacer to mirror the space occupied by the "Switch Servers" button
|
||||||
|
Spacer(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(
|
.height(56.dp),
|
||||||
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
|
// approximate TV button height
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
},
|
||||||
|
DialogItem(
|
||||||
|
stringResource(R.string.delete),
|
||||||
|
Icons.Default.Delete,
|
||||||
|
Color.Red.copy(alpha = .8f),
|
||||||
|
) {
|
||||||
|
viewModel.removeServer(server)
|
||||||
|
showDeleteDialog = null
|
||||||
|
},
|
||||||
|
),
|
||||||
|
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 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showAddServer) {
|
val firstDiscoveredServerFocusRequester = remember { FocusRequester() }
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
viewModel.clearAddServerState()
|
// Default focus to first discovered server if available
|
||||||
|
LaunchedEffect(filteredDiscoveredServers.isNotEmpty(), showEnterAddress) {
|
||||||
|
if (!showEnterAddress && filteredDiscoveredServers.isNotEmpty()) {
|
||||||
|
firstDiscoveredServerFocusRequester.tryRequestFocus()
|
||||||
}
|
}
|
||||||
val state by viewModel.addServerState.observeAsState(LoadingState.Pending)
|
|
||||||
val url = rememberTextFieldState()
|
|
||||||
val submit = {
|
|
||||||
viewModel.addServer(url.text.toString())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
BasicDialog(
|
BasicDialog(
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
showAddServer = false
|
showAddServer = false
|
||||||
|
showEnterAddress = false
|
||||||
viewModel.clearAddServerState()
|
viewModel.clearAddServerState()
|
||||||
},
|
},
|
||||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||||
elevation = 10.dp,
|
elevation = 10.dp,
|
||||||
) {
|
) {
|
||||||
val focusRequester = remember { FocusRequester() }
|
|
||||||
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
|
@ -194,11 +249,96 @@ fun SwitchServerContent(
|
||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
.fillMaxWidth(.4f),
|
.fillMaxWidth(.4f),
|
||||||
) {
|
) {
|
||||||
|
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 = 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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
text = stringResource(R.string.enter_server_url),
|
text = stringResource(R.string.enter_server_url),
|
||||||
)
|
)
|
||||||
EditTextBox(
|
EditTextBox(
|
||||||
state = url,
|
value = url,
|
||||||
|
onValueChange = { url = it },
|
||||||
keyboardOptions =
|
keyboardOptions =
|
||||||
KeyboardOptions(
|
KeyboardOptions(
|
||||||
capitalization = KeyboardCapitalization.None,
|
capitalization = KeyboardCapitalization.None,
|
||||||
|
|
@ -206,10 +346,13 @@ fun SwitchServerContent(
|
||||||
keyboardType = KeyboardType.Uri,
|
keyboardType = KeyboardType.Uri,
|
||||||
imeAction = ImeAction.Go,
|
imeAction = ImeAction.Go,
|
||||||
),
|
),
|
||||||
onKeyboardAction = { submit.invoke() },
|
keyboardActions =
|
||||||
|
KeyboardActions(
|
||||||
|
onGo = { submit.invoke() },
|
||||||
|
),
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.focusRequester(focusRequester)
|
.focusRequester(textBoxFocusRequester)
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
when (val st = state) {
|
when (val st = state) {
|
||||||
|
|
@ -226,7 +369,7 @@ fun SwitchServerContent(
|
||||||
}
|
}
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = { submit.invoke() },
|
onClick = { submit.invoke() },
|
||||||
enabled = url.text.isNotNullOrBlank() && state == LoadingState.Pending,
|
enabled = url.isNotNullOrBlank() && state == LoadingState.Pending,
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
if (state == LoadingState.Loading) {
|
if (state == LoadingState.Loading) {
|
||||||
|
|
@ -240,3 +383,4 @@ fun SwitchServerContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package com.github.damontecres.wholphin.ui.setup
|
package com.github.damontecres.wholphin.ui.setup
|
||||||
|
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.focusGroup
|
import androidx.compose.foundation.focusGroup
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
|
@ -11,9 +10,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
|
@ -36,7 +34,6 @@ import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import androidx.tv.material3.surfaceColorAtElevation
|
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
|
|
@ -94,16 +91,16 @@ fun SwitchUserContent(
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth(.5f)
|
.fillMaxWidth()
|
||||||
.align(Alignment.Center)
|
.align(Alignment.Center)
|
||||||
.padding(16.dp)
|
.padding(16.dp),
|
||||||
.background(
|
) {
|
||||||
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
|
Column(
|
||||||
shape = RoundedCornerShape(16.dp),
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.select_user),
|
text = stringResource(R.string.select_user),
|
||||||
|
|
@ -115,6 +112,7 @@ fun SwitchUserContent(
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
UserList(
|
UserList(
|
||||||
users = users,
|
users = users,
|
||||||
currentUser = currentUser,
|
currentUser = currentUser,
|
||||||
|
|
@ -134,13 +132,7 @@ fun SwitchUserContent(
|
||||||
onSwitchServer = {
|
onSwitchServer = {
|
||||||
viewModel.navigationManager.navigateTo(Destination.ServerList)
|
viewModel.navigationManager.navigateTo(Destination.ServerList)
|
||||||
},
|
},
|
||||||
modifier =
|
modifier = Modifier.fillMaxWidth(),
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
|
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -170,7 +162,7 @@ fun SwitchUserContent(
|
||||||
Modifier
|
Modifier
|
||||||
.focusGroup()
|
.focusGroup()
|
||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
.fillMaxWidth(.66f),
|
.fillMaxWidth(.4f),
|
||||||
) {
|
) {
|
||||||
if (useQuickConnect) {
|
if (useQuickConnect) {
|
||||||
if (quickConnect == null && userState !is LoadingState.Error) {
|
if (quickConnect == null && userState !is LoadingState.Error) {
|
||||||
|
|
@ -196,6 +188,8 @@ fun SwitchUserContent(
|
||||||
text = "Use Quick Connect on your device to authenticate to ${server.name ?: server.url}",
|
text = "Use Quick Connect on your device to authenticate to ${server.name ?: server.url}",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = quickConnect?.code ?: "Failed to get code",
|
text = quickConnect?.code ?: "Failed to get code",
|
||||||
|
|
@ -215,13 +209,15 @@ fun SwitchUserContent(
|
||||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
val username = rememberTextFieldState()
|
// val username = rememberTextFieldState()
|
||||||
val password = rememberTextFieldState()
|
// val password = rememberTextFieldState()
|
||||||
|
var username by remember { mutableStateOf("") }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
val onSubmit = {
|
val onSubmit = {
|
||||||
viewModel.login(
|
viewModel.login(
|
||||||
server,
|
server,
|
||||||
username.text.toString(),
|
username,
|
||||||
password.text.toString(),
|
password,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
@ -231,6 +227,8 @@ fun SwitchUserContent(
|
||||||
text = "Enter username/password to login to ${server.name ?: server.url}",
|
text = "Enter username/password to login to ${server.name ?: server.url}",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
UserStateError(userState)
|
UserStateError(userState)
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -242,7 +240,8 @@ fun SwitchUserContent(
|
||||||
modifier = Modifier.padding(end = 8.dp),
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
)
|
)
|
||||||
EditTextBox(
|
EditTextBox(
|
||||||
state = username,
|
value = username,
|
||||||
|
onValueChange = { username = it },
|
||||||
keyboardOptions =
|
keyboardOptions =
|
||||||
KeyboardOptions(
|
KeyboardOptions(
|
||||||
capitalization = KeyboardCapitalization.None,
|
capitalization = KeyboardCapitalization.None,
|
||||||
|
|
@ -250,9 +249,15 @@ fun SwitchUserContent(
|
||||||
keyboardType = KeyboardType.Text,
|
keyboardType = KeyboardType.Text,
|
||||||
imeAction = ImeAction.Next,
|
imeAction = ImeAction.Next,
|
||||||
),
|
),
|
||||||
onKeyboardAction = {
|
keyboardActions =
|
||||||
|
KeyboardActions(
|
||||||
|
onNext = {
|
||||||
passwordFocusRequester.tryRequestFocus()
|
passwordFocusRequester.tryRequestFocus()
|
||||||
},
|
},
|
||||||
|
),
|
||||||
|
// onKeyboardAction = {
|
||||||
|
// passwordFocusRequester.tryRequestFocus()
|
||||||
|
// },
|
||||||
isInputValid = { userState !is LoadingState.Error },
|
isInputValid = { userState !is LoadingState.Error },
|
||||||
modifier = Modifier.focusRequester(focusRequester),
|
modifier = Modifier.focusRequester(focusRequester),
|
||||||
)
|
)
|
||||||
|
|
@ -265,12 +270,12 @@ fun SwitchUserContent(
|
||||||
text = "Password",
|
text = "Password",
|
||||||
modifier = Modifier.padding(end = 8.dp),
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
)
|
)
|
||||||
LaunchedEffect(password.text) {
|
LaunchedEffect(password) {
|
||||||
viewModel.clearSwitchUserState()
|
viewModel.clearSwitchUserState()
|
||||||
}
|
}
|
||||||
EditTextBox(
|
EditTextBox(
|
||||||
isPassword = true,
|
value = password,
|
||||||
state = password,
|
onValueChange = { password = it },
|
||||||
keyboardOptions =
|
keyboardOptions =
|
||||||
KeyboardOptions(
|
KeyboardOptions(
|
||||||
capitalization = KeyboardCapitalization.None,
|
capitalization = KeyboardCapitalization.None,
|
||||||
|
|
@ -278,7 +283,10 @@ fun SwitchUserContent(
|
||||||
keyboardType = KeyboardType.Password,
|
keyboardType = KeyboardType.Password,
|
||||||
imeAction = ImeAction.Go,
|
imeAction = ImeAction.Go,
|
||||||
),
|
),
|
||||||
onKeyboardAction = { onSubmit.invoke() },
|
keyboardActions =
|
||||||
|
KeyboardActions(
|
||||||
|
onGo = { onSubmit.invoke() },
|
||||||
|
),
|
||||||
isInputValid = { userState !is LoadingState.Error },
|
isInputValid = { userState !is LoadingState.Error },
|
||||||
modifier = Modifier.focusRequester(passwordFocusRequester),
|
modifier = Modifier.focusRequester(passwordFocusRequester),
|
||||||
)
|
)
|
||||||
|
|
@ -286,7 +294,7 @@ fun SwitchUserContent(
|
||||||
TextButton(
|
TextButton(
|
||||||
stringRes = R.string.login,
|
stringRes = R.string.login,
|
||||||
onClick = { onSubmit.invoke() },
|
onClick = { onSubmit.invoke() },
|
||||||
enabled = username.text.isNotNullOrBlank() && password.text.isNotNullOrBlank(),
|
enabled = username.isNotNullOrBlank(),
|
||||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import com.github.damontecres.wholphin.data.JellyfinServerDao
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
|
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||||
|
|
@ -25,6 +26,7 @@ import org.jellyfin.sdk.Jellyfin
|
||||||
import org.jellyfin.sdk.api.client.HttpClientOptions
|
import org.jellyfin.sdk.api.client.HttpClientOptions
|
||||||
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
|
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
|
||||||
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
|
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.quickConnectApi
|
import org.jellyfin.sdk.api.client.extensions.quickConnectApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.systemApi
|
import org.jellyfin.sdk.api.client.extensions.systemApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.userApi
|
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||||
|
|
@ -41,6 +43,7 @@ class SwitchUserViewModel
|
||||||
val serverRepository: ServerRepository,
|
val serverRepository: ServerRepository,
|
||||||
val serverDao: JellyfinServerDao,
|
val serverDao: JellyfinServerDao,
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
|
val imageUrlService: ImageUrlService,
|
||||||
@Assisted val server: JellyfinServer,
|
@Assisted val server: JellyfinServer,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
|
|
@ -50,7 +53,7 @@ class SwitchUserViewModel
|
||||||
|
|
||||||
val serverQuickConnect = MutableLiveData<Boolean>(false)
|
val serverQuickConnect = MutableLiveData<Boolean>(false)
|
||||||
|
|
||||||
val users = MutableLiveData<List<JellyfinUser>>(listOf())
|
val users = MutableLiveData<List<JellyfinUserAndImage>>(listOf())
|
||||||
val quickConnectState = MutableLiveData<QuickConnectResult?>(null)
|
val quickConnectState = MutableLiveData<QuickConnectResult?>(null)
|
||||||
|
|
||||||
private var quickConnectJob: Job? = null
|
private var quickConnectJob: Job? = null
|
||||||
|
|
@ -78,8 +81,7 @@ class SwitchUserViewModel
|
||||||
quickConnectJob?.cancel()
|
quickConnectJob?.cancel()
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
users.setValueOnMain(listOf())
|
users.setValueOnMain(listOf())
|
||||||
val serverUsers =
|
val serverUsers = getUsers()
|
||||||
serverDao.getServer(server.id)?.users?.sortedBy { it.name } ?: listOf()
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
users.setValueOnMain(serverUsers)
|
users.setValueOnMain(serverUsers)
|
||||||
}
|
}
|
||||||
|
|
@ -215,14 +217,23 @@ class SwitchUserViewModel
|
||||||
fun removeUser(user: JellyfinUser) {
|
fun removeUser(user: JellyfinUser) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
serverRepository.removeUser(user)
|
serverRepository.removeUser(user)
|
||||||
val serverUsers =
|
val serverUsers = getUsers()
|
||||||
serverDao.getServer(user.serverId)?.users?.sortedBy { it.name } ?: listOf()
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
users.value = serverUsers
|
users.value = serverUsers
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun getUsers(): List<JellyfinUserAndImage> {
|
||||||
|
val api = jellyfin.createApi(server.url)
|
||||||
|
return serverDao
|
||||||
|
.getServer(server.id)
|
||||||
|
?.users
|
||||||
|
?.sortedBy { it.name }
|
||||||
|
?.map { JellyfinUserAndImage(it, api.imageApi.getUserImageUrl(it.id)) }
|
||||||
|
.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun setError(
|
private suspend fun setError(
|
||||||
msg: String? = null,
|
msg: String? = null,
|
||||||
ex: Exception? = null,
|
ex: Exception? = null,
|
||||||
|
|
@ -231,3 +242,8 @@ class SwitchUserViewModel
|
||||||
switchUserState.value = LoadingState.Error(msg, ex)
|
switchUserState.value = LoadingState.Error(msg, ex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class JellyfinUserAndImage(
|
||||||
|
val user: JellyfinUser,
|
||||||
|
val imageUrl: String?,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,68 @@
|
||||||
package com.github.damontecres.wholphin.ui.setup
|
package com.github.damontecres.wholphin.ui.setup
|
||||||
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.wrapContentWidth
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Check
|
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.tv.material3.Border
|
||||||
|
import androidx.tv.material3.Button
|
||||||
|
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||||
import androidx.tv.material3.Icon
|
import androidx.tv.material3.Icon
|
||||||
import androidx.tv.material3.ListItem
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Surface
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
|
import com.github.damontecres.wholphin.ui.Cards
|
||||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogItem
|
import com.github.damontecres.wholphin.ui.components.DialogItem
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||||
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display a list of users plus option to add a new one or switch servers
|
* Display a list of users plus option to add a new one or switch servers
|
||||||
|
* Redesigned to match streaming service style with horizontal scrollable user icons
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun UserList(
|
fun UserList(
|
||||||
users: List<JellyfinUser>,
|
users: List<JellyfinUserAndImage>,
|
||||||
currentUser: JellyfinUser?,
|
currentUser: JellyfinUser?,
|
||||||
onSwitchUser: (JellyfinUser) -> Unit,
|
onSwitchUser: (JellyfinUser) -> Unit,
|
||||||
onAddUser: () -> Unit,
|
onAddUser: () -> Unit,
|
||||||
|
|
@ -41,61 +72,78 @@ fun UserList(
|
||||||
) {
|
) {
|
||||||
var showDeleteDialog by remember { mutableStateOf<JellyfinUser?>(null) }
|
var showDeleteDialog by remember { mutableStateOf<JellyfinUser?>(null) }
|
||||||
|
|
||||||
LazyColumn(modifier = modifier) {
|
Column(
|
||||||
items(users) { user ->
|
modifier = modifier,
|
||||||
ListItem(
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
enabled = true,
|
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
selected = user == currentUser,
|
) {
|
||||||
headlineContent = { Text(text = user.name ?: user.id.toString()) },
|
// Horizontal scrollable list of user icons - centered
|
||||||
leadingContent = {
|
Box(
|
||||||
if (user.id == currentUser?.id) {
|
modifier = Modifier.fillMaxWidth(),
|
||||||
Icon(
|
contentAlignment = Alignment.Center,
|
||||||
imageVector = Icons.Default.Check,
|
) {
|
||||||
contentDescription = "current user",
|
val focusRequester = remember { FocusRequester() }
|
||||||
)
|
val firstFocusRequester = remember { FocusRequester() }
|
||||||
}
|
if (users.isNotEmpty()) {
|
||||||
},
|
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||||
onClick = { onSwitchUser.invoke(user) },
|
}
|
||||||
onLongClick = {
|
LazyRow(
|
||||||
showDeleteDialog = user
|
horizontalArrangement = Arrangement.spacedBy(24.dp), // Spacing to accommodate 20% scale
|
||||||
},
|
contentPadding = PaddingValues(horizontal = 48.dp, vertical = 16.dp), // Increased padding to accommodate 20% scale
|
||||||
modifier = Modifier,
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.wrapContentWidth()
|
||||||
|
.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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Add User card - always rightmost
|
||||||
item {
|
item {
|
||||||
HorizontalDivider()
|
AddUserCard(
|
||||||
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,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = { onAddUser.invoke() },
|
onClick = { onAddUser.invoke() },
|
||||||
modifier = Modifier,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
}
|
||||||
HorizontalDivider()
|
}
|
||||||
ListItem(
|
|
||||||
enabled = true,
|
// Switch servers button below user list - centered
|
||||||
selected = false,
|
Row(
|
||||||
headlineContent = { Text(text = stringResource(R.string.switch_servers)) },
|
horizontalArrangement = Arrangement.Center,
|
||||||
leadingContent = {
|
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(
|
||||||
text = stringResource(R.string.fa_arrow_left_arrow_right),
|
text = stringResource(R.string.fa_arrow_left_arrow_right),
|
||||||
fontFamily = FontAwesome,
|
fontFamily = FontAwesome,
|
||||||
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
)
|
)
|
||||||
},
|
Text(
|
||||||
onClick = { onSwitchServer.invoke() },
|
text = stringResource(R.string.switch_servers),
|
||||||
modifier = Modifier,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
showDeleteDialog?.let { user ->
|
showDeleteDialog?.let { user ->
|
||||||
DialogPopup(
|
DialogPopup(
|
||||||
showDialog = true,
|
showDialog = true,
|
||||||
|
|
@ -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