Merge branch 'main' into fea/jellyseerr

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

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

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

4
.gitignore vendored
View file

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

View file

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

View file

@ -23,9 +23,17 @@ We follow GitHub's fork & pull request model for contributions.
After forking and cloning your fork, you can import the project into Android Studio.
You need a compatible Android Studio version for the configured AGP. This is generally `Narwhal 3 Feature Drop | 2025.1.3` or newer. See https://developer.android.com/build/releases/gradle-plugin and [`libs.versions.toml](./gradle/libs.versions.toml).
### Development environment
### Code organization
It is recommended to use a recent version of [Android Studio](https://developer.android.com/studio). Make sure the [version is compatible](https://developer.android.com/build/releases/gradle-plugin#android_gradle_plugin_and_android_studio_compatibility) with Wholphin's AGP version.
Code formatting should follow [ktlint's](https://github.com/pinterest/ktlint) rules. Find the `ktlint` version in [`.pre-commit-config.yaml`](./.pre-commit-config.yaml). Optionally, install the [ktlint plugin](https://plugins.jetbrains.com/plugin/15057-ktlint) in Android Studio to run automatically. Configure the version in `Settings->Tools->KtLint->Ruleset Version`.
Also, it's recommend to add an extra ruleset jar for Compose-specific KtLint: https://mrmans0n.github.io/compose-rules/ktlint/#using-with-ktlint-cli-or-the-ktlint-intellij-plugin
Also setup [pre-commit](https://github.com/pre-commit/pre-commit) which will run `ktlint` as well on each commit, plus check for other common issues.
## Code organization
Code is split into several packages:
- `data` - app-specific data models and services

View file

@ -7,15 +7,21 @@ Wholphin is an open-source Android TV client for Jellyfin. It aims to provide a
This is not a fork of the [official client](https://github.com/jellyfin/jellyfin-androidtv). Wholphin's user interface and controls have been written completely from scratch. Wholphin `v0.3.0+` supports playing media using either ExoPlayer/Media3 or MPV (experimental).
<p align="center">
<a href="https://github.com/damontecres/Wholphin/issues/303"><b>Help get Wholphin listed on the Play Store</b></a>
<br/>
<br/>
<a href="https://github.com/damontecres/Wholphin/releases">
<img alt="Current Release" src="https://img.shields.io/github/release/damontecres/wholphin.svg"/>
</a>
<a href="https://translate.codeberg.org/engage/wholphin/">
<img src="https://translate.codeberg.org/widget/wholphin/wholphin/svg-badge.svg" alt="Translation status" />
</a>
<br/>
<a href="https://play.google.com/store/apps/details?id=com.github.damontecres.wholphin">
<img width="180" alt="Get Wholphin on Google Play" src="https://github.com/user-attachments/assets/2550a4cb-ce46-47a1-ae24-f33a169234b7"/>
</a>
<a href="https://www.amazon.com/gp/product/B0G8RQQR9T/ref=mas_pm_wholphin">
<img width="180" alt="Get Wholphin on Amazon AppStore" src="https://github.com/user-attachments/assets/1f3a3b26-4b4f-44b1-9741-f4c895c8a53b"/>
</a>
</p>
<img width="1280" height="720" alt="0_3_5_home" src="https://github.com/user-attachments/assets/a485c015-ec21-442d-a757-1f18381bf799" />
@ -24,12 +30,12 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin
### User interface
- A navigation drawer for quick access to libraries, search, and settings from almost anywhere in the app
- A navigation drawer for quick access to libraries, favorites, search, and settings from almost anywhere in the app
- Option to combine Continue Watching & Next Up rows
- Show Movie/TV Show titles when browsing libraries
- Play theme music, if available
- Search & download subtitles (requires compatible server plugin such as [OpenSubtitles](https://github.com/jellyfin/jellyfin-plugin-opensubtitles))
- Customize layout grids for libraries
- Access all your favorites quickly from the nav drawer
- Multiple app color themes
### Playback
@ -43,6 +49,8 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin
- Optionally skip back a few seconds when resuming playback
- Live TV & DVR support
- Auto play next episodes with pass out protection
- Option for automatic refresh rate switching on supported displays
- Trickplay support
- Other (subjective) enhancements:
- Subtly show playback position along the bottom of the screen while seeking w/ D-Pad
- Force Continue Watching & Next Up TV episodes to use their Series posters

View file

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

View file

@ -0,0 +1,383 @@
{
"formatVersion": 1,
"database": {
"version": 11,
"identityHash": "26bbe361923e3aac064c08c52e65264e",
"entities": [
{
"tableName": "servers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT"
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "users",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "rowId",
"columnName": "rowId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT"
},
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accessToken",
"columnName": "accessToken",
"affinity": "TEXT"
},
{
"fieldPath": "pin",
"columnName": "pin",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"rowId"
]
},
"indices": [
{
"name": "index_users_id_serverId",
"unique": true,
"columnNames": [
"id",
"serverId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)"
},
{
"name": "index_users_id",
"unique": false,
"columnNames": [
"id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)"
},
{
"name": "index_users_serverId",
"unique": false,
"columnNames": [
"serverId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)"
}
],
"foreignKeys": [
{
"table": "servers",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serverId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "ItemPlayback",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "rowId",
"columnName": "rowId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sourceId",
"columnName": "sourceId",
"affinity": "TEXT"
},
{
"fieldPath": "audioIndex",
"columnName": "audioIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "subtitleIndex",
"columnName": "subtitleIndex",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"rowId"
]
},
"indices": [
{
"name": "index_ItemPlayback_userId_itemId",
"unique": true,
"columnNames": [
"userId",
"itemId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "NavDrawerPinnedItem",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"itemId"
]
},
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "LibraryDisplayInfo",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sort",
"columnName": "sort",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "direction",
"columnName": "direction",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "filter",
"columnName": "filter",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'{}'"
},
{
"fieldPath": "viewOptions",
"columnName": "viewOptions",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"itemId"
]
},
"indices": [
{
"name": "index_LibraryDisplayInfo_userId_itemId",
"unique": true,
"columnNames": [
"userId",
"itemId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "PlaybackLanguageChoice",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "seriesId",
"columnName": "seriesId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT"
},
{
"fieldPath": "audioLanguage",
"columnName": "audioLanguage",
"affinity": "TEXT"
},
{
"fieldPath": "subtitleLanguage",
"columnName": "subtitleLanguage",
"affinity": "TEXT"
},
{
"fieldPath": "subtitlesDisabled",
"columnName": "subtitlesDisabled",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"seriesId"
]
},
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '26bbe361923e3aac064c08c52e65264e')"
]
}
}

View file

@ -0,0 +1,434 @@
{
"formatVersion": 1,
"database": {
"version": 12,
"identityHash": "e9bcae45448f995d1f4ebf291e45c510",
"entities": [
{
"tableName": "servers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT"
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "users",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "rowId",
"columnName": "rowId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT"
},
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accessToken",
"columnName": "accessToken",
"affinity": "TEXT"
},
{
"fieldPath": "pin",
"columnName": "pin",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"rowId"
]
},
"indices": [
{
"name": "index_users_id_serverId",
"unique": true,
"columnNames": [
"id",
"serverId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)"
},
{
"name": "index_users_id",
"unique": false,
"columnNames": [
"id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)"
},
{
"name": "index_users_serverId",
"unique": false,
"columnNames": [
"serverId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)"
}
],
"foreignKeys": [
{
"table": "servers",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serverId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "ItemPlayback",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "rowId",
"columnName": "rowId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sourceId",
"columnName": "sourceId",
"affinity": "TEXT"
},
{
"fieldPath": "audioIndex",
"columnName": "audioIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "subtitleIndex",
"columnName": "subtitleIndex",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"rowId"
]
},
"indices": [
{
"name": "index_ItemPlayback_userId_itemId",
"unique": true,
"columnNames": [
"userId",
"itemId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "NavDrawerPinnedItem",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"itemId"
]
},
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "LibraryDisplayInfo",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sort",
"columnName": "sort",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "direction",
"columnName": "direction",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "filter",
"columnName": "filter",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'{}'"
},
{
"fieldPath": "viewOptions",
"columnName": "viewOptions",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"itemId"
]
},
"indices": [
{
"name": "index_LibraryDisplayInfo_userId_itemId",
"unique": true,
"columnNames": [
"userId",
"itemId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "PlaybackLanguageChoice",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "seriesId",
"columnName": "seriesId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT"
},
{
"fieldPath": "audioLanguage",
"columnName": "audioLanguage",
"affinity": "TEXT"
},
{
"fieldPath": "subtitleLanguage",
"columnName": "subtitleLanguage",
"affinity": "TEXT"
},
{
"fieldPath": "subtitlesDisabled",
"columnName": "subtitlesDisabled",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"seriesId"
]
},
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "ItemTrackModification",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "trackIndex",
"columnName": "trackIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "delayMs",
"columnName": "delayMs",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"itemId",
"trackIndex"
]
},
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e9bcae45448f995d1f4ebf291e45c510')"
]
}
}

View file

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

View file

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

View file

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

View file

@ -2,10 +2,14 @@ package com.github.damontecres.wholphin.data
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.ItemTrackModification
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.data.model.chooseSource
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.StreamChoiceService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
@ -13,6 +17,7 @@ import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration
@Singleton
class ItemPlaybackRepository
@ -20,47 +25,60 @@ class ItemPlaybackRepository
constructor(
val serverRepository: ServerRepository,
val itemPlaybackDao: ItemPlaybackDao,
private val streamChoiceService: StreamChoiceService,
) {
fun getSelectedTracks(
suspend fun getSelectedTracks(
itemId: UUID,
item: BaseItem,
prefs: UserPreferences,
): ChosenStreams? =
serverRepository.currentUser.value?.let { user ->
val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId)
if (itemPlayback != null) {
Timber.v("Got itemPlayback for %s", itemId)
getChosenItemFromPlayback(item, itemPlayback)
} else {
null
}
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
Timber.v("For ${item.id}: itemPlayback=${itemPlayback != null}, plc=${plc != null}")
return getChosenItemFromPlayback(item, itemPlayback, plc, prefs)
}
fun getChosenItemFromPlayback(
item: BaseItem,
itemPlayback: ItemPlayback,
itemPlayback: ItemPlayback?,
plc: PlaybackLanguageChoice?,
prefs: UserPreferences,
): ChosenStreams? {
val source =
item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback.sourceId }
item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback?.sourceId }
?: streamChoiceService.chooseSource(item.data.mediaSources.orEmpty())
if (source != null) {
val audioStream =
if (itemPlayback.audioIndexEnabled) {
source.mediaStreams?.firstOrNull { it.index == itemPlayback.audioIndex }
} else {
null
}
streamChoiceService.chooseAudioStream(
candidates =
source.mediaStreams
?.filter { it.type == MediaStreamType.AUDIO }
.orEmpty(),
itemPlayback = itemPlayback,
playbackLanguageChoice = plc,
prefs = prefs,
)
val subtitleStream =
if (itemPlayback.subtitleIndexEnabled) {
source.mediaStreams?.firstOrNull { it.index == itemPlayback.subtitleIndex }
} else {
null
}
streamChoiceService.chooseSubtitleStream(
audioStream = audioStream,
candidates =
source.mediaStreams
?.filter { it.type == MediaStreamType.SUBTITLE }
.orEmpty(),
itemPlayback = itemPlayback,
playbackLanguageChoice = plc,
prefs = prefs,
)
return ChosenStreams(
itemPlayback,
item.id,
source.id?.toUUIDOrNull(),
audioStream,
subtitleStream,
itemPlayback.subtitleIndex == TrackIndex.DISABLED,
itemPlayback = itemPlayback,
plc = plc,
itemId = item.id,
source = source,
videoStream = source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO },
audioStream = audioStream,
subtitleStream = subtitleStream,
subtitlesDisabled = itemPlayback?.subtitleIndex == TrackIndex.DISABLED,
)
} else {
return null
@ -89,22 +107,58 @@ class ItemPlaybackRepository
itemPlayback: ItemPlayback?,
trackIndex: Int,
type: MediaStreamType,
) = serverRepository.currentUser.value?.let { user ->
var toSave =
itemPlayback ?: ItemPlayback(
userId = user.rowId,
itemId = item.id,
sourceId = chooseSource(item.data, null)?.id?.toUUIDOrNull(),
)
toSave =
when (type) {
MediaStreamType.AUDIO -> toSave.copy(audioIndex = trackIndex)
MediaStreamType.SUBTITLE -> toSave.copy(subtitleIndex = trackIndex)
else -> toSave
): ItemPlayback =
serverRepository.current.value!!.let { current ->
val source =
itemPlayback?.sourceId?.let { sourceId ->
item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == sourceId }
} ?: streamChoiceService.chooseSource(item.data, null)
if (source == null) {
Timber.w("Could not find media source for ${item.id}")
throw IllegalArgumentException("Could not find media source for ${item.id}")
}
Timber.v("Saving track selection %s", toSave)
saveItemPlayback(toSave)
}
var toSave =
itemPlayback ?: ItemPlayback(
userId = current.user.rowId,
itemId = item.id,
sourceId = source.id?.toUUIDOrNull(),
)
toSave =
when (type) {
MediaStreamType.AUDIO -> toSave.copy(audioIndex = trackIndex)
MediaStreamType.SUBTITLE -> toSave.copy(subtitleIndex = trackIndex)
else -> toSave
}
Timber.v("Saving track selection %s", toSave)
toSave = saveItemPlayback(toSave)
val seriesId = item.data.seriesId
if (seriesId != null && trackIndex != TrackIndex.UNSPECIFIED) {
if (type == MediaStreamType.AUDIO) {
val stream = source.mediaStreams?.first { it.index == trackIndex }
if (stream?.language != null) {
streamChoiceService.updateAudio(item.data, stream.language!!)
}
} else if (type == MediaStreamType.SUBTITLE) {
if (trackIndex == TrackIndex.DISABLED) {
streamChoiceService.updateSubtitles(
item.data,
subtitleLang = null,
subtitlesDisabled = true,
)
} else {
val stream = source.mediaStreams?.first { it.index == trackIndex }
if (stream?.language != null) {
streamChoiceService.updateSubtitles(
item.data,
stream.language!!,
subtitlesDisabled = false,
)
}
}
}
}
toSave
}
/**
* Saves the [ItemPlayback] into the database, returning the same object with the rowId updated if needed
@ -124,12 +178,40 @@ class ItemPlaybackRepository
val id = itemPlaybackDao.saveItem(toSave)
return toSave.copy(rowId = id)
}
suspend fun getTrackModifications(
itemId: UUID,
trackIndex: Int,
): ItemTrackModification? =
serverRepository.currentUser.value?.rowId?.let { userId ->
itemPlaybackDao.getTrackModifications(userId, itemId, trackIndex)
}
suspend fun saveTrackModifications(
itemId: UUID,
trackIndex: Int,
delay: Duration,
) {
serverRepository.currentUser.value?.rowId?.let { userId ->
Timber.v("Saving track mod item=%s, track=%s, delay=%s", itemId, trackIndex, delay)
itemPlaybackDao.saveItem(
ItemTrackModification(
userId,
itemId,
trackIndex,
delay.inWholeMilliseconds,
),
)
}
}
}
data class ChosenStreams(
val itemPlayback: ItemPlayback,
val itemPlayback: ItemPlayback?,
val plc: PlaybackLanguageChoice?,
val itemId: UUID,
val sourceId: UUID?,
val source: MediaSourceInfo,
val videoStream: MediaStream?,
val audioStream: MediaStream?,
val subtitleStream: MediaStream?,
val subtitlesDisabled: Boolean,

View file

@ -0,0 +1,20 @@
package com.github.damontecres.wholphin.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
import java.util.UUID
@Dao
interface PlaybackLanguageChoiceDao {
@Query("SELECT * FROM PlaybackLanguageChoice WHERE userId=:userId AND seriesId=:seriesId")
suspend fun get(
userId: Int,
seriesId: UUID,
): PlaybackLanguageChoice?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun save(plc: PlaybackLanguageChoice): Long
}

View file

@ -126,7 +126,9 @@ class ServerRepository
}
if (serverAndUsers != null) {
val user = serverAndUsers.users.firstOrNull { it.id == userId }
if (user != null && !user.hasPin) {
if (user != null) {
// TODO pin-related
// if (user != null && !user.hasPin) {
changeUser(serverAndUsers.server, user)
return true
}

View file

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

View file

@ -7,19 +7,10 @@ import androidx.room.ForeignKey
import androidx.room.Ignore
import androidx.room.Index
import androidx.room.PrimaryKey
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID
@Entity(
@ -53,122 +44,6 @@ data class ItemPlayback(
val subtitleIndexEnabled = subtitleIndex >= 0
}
/**
* Returns the [MediaSourceInfo] with the highest video resolution
*/
fun chooseSource(sources: List<MediaSourceInfo>?) =
sources?.letNotEmpty { sources ->
val result =
sources.maxByOrNull { s ->
s.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }?.let { video ->
(video.width ?: 0) * (video.height ?: 0)
} ?: 0
}
result
}
/**
* Returns the [MediaSourceInfo] that matched the [ItemPlayback] or else the one with the highest resolution
*/
fun chooseSource(
dto: BaseItemDto,
itemPlayback: ItemPlayback?,
) = itemPlayback?.sourceId?.let { dto.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback.sourceId } }
?: chooseSource(dto.mediaSources) // dto.mediaSources?.firstOrNull()
fun chooseStream(
dto: BaseItemDto,
itemPlayback: ItemPlayback?,
type: MediaStreamType,
prefs: UserPreferences,
): MediaStream? {
val source = chooseSource(dto, itemPlayback)
return source?.mediaStreams?.letNotEmpty { streams ->
val candidates = streams.filter { it.type == type }
when (type) {
MediaStreamType.AUDIO -> chooseAudioStream(candidates, itemPlayback, prefs)
MediaStreamType.SUBTITLE -> chooseSubtitleStream(candidates, itemPlayback, prefs)
else -> candidates.firstOrNull()
}
}
}
fun chooseAudioStream(
candidates: List<MediaStream>,
itemPlayback: ItemPlayback?,
prefs: UserPreferences,
): MediaStream? =
if (itemPlayback?.audioIndexEnabled == true) {
candidates.firstOrNull { it.index == itemPlayback.audioIndex }
} else {
// TODO audio selection based on channel layout or preferences or default
val audioLanguage = prefs.userConfig.audioLanguagePreference
if (audioLanguage.isNotNullOrBlank()) {
val sorted =
candidates.sortedWith(compareBy<MediaStream> { it.language }.thenByDescending { it.channels })
sorted.firstOrNull { it.language == audioLanguage && it.isDefault }
?: sorted.firstOrNull { it.language == audioLanguage }
?: sorted.firstOrNull { it.isDefault }
?: sorted.firstOrNull()
} else {
candidates.firstOrNull { it.isDefault }
?: candidates.firstOrNull()
}
}
fun chooseSubtitleStream(
candidates: List<MediaStream>,
itemPlayback: ItemPlayback?,
prefs: UserPreferences,
): MediaStream? {
if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) {
return null
} else if (itemPlayback?.subtitleIndexEnabled == true) {
return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex }
} else {
val subtitleLanguage = prefs.userConfig.subtitleLanguagePreference
return when (prefs.userConfig.subtitleMode) {
SubtitlePlaybackMode.ALWAYS -> {
if (subtitleLanguage != null) {
candidates.firstOrNull { it.language == subtitleLanguage }
} else {
candidates.firstOrNull()
}
}
SubtitlePlaybackMode.ONLY_FORCED -> {
if (subtitleLanguage != null) {
candidates.firstOrNull { it.language == subtitleLanguage && it.isForced }
} else {
candidates.firstOrNull { it.isForced }
}
}
SubtitlePlaybackMode.SMART -> {
val audioLanguage = prefs.userConfig.audioLanguagePreference
if (audioLanguage != null && subtitleLanguage != null && audioLanguage != subtitleLanguage) {
candidates.firstOrNull { it.language == subtitleLanguage }
} else {
null
}
}
SubtitlePlaybackMode.DEFAULT -> {
// TODO check for language?
(
candidates.firstOrNull { it.isDefault && it.isForced }
?: candidates.firstOrNull { it.isDefault }
?: candidates.firstOrNull { it.isForced }
)
}
SubtitlePlaybackMode.NONE -> {
null
}
}
}
}
object TrackIndex {
/**
* The user has not explicitly specified a track to use

View file

@ -0,0 +1,30 @@
@file:UseSerializers(UUIDSerializer::class)
package com.github.damontecres.wholphin.data.model
import androidx.room.Entity
import androidx.room.ForeignKey
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID
@Entity(
foreignKeys = [
ForeignKey(
entity = JellyfinUser::class,
parentColumns = arrayOf("rowId"),
childColumns = arrayOf("userId"),
onDelete = ForeignKey.CASCADE,
onUpdate = ForeignKey.CASCADE,
),
],
primaryKeys = ["userId", "itemId", "trackIndex"],
)
@Serializable
data class ItemTrackModification(
val userId: Int,
val itemId: UUID,
val trackIndex: Int,
val delayMs: Long,
)

View file

@ -0,0 +1,32 @@
@file:UseSerializers(UUIDSerializer::class)
package com.github.damontecres.wholphin.data.model
import androidx.room.Entity
import androidx.room.ForeignKey
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID
@Entity(
foreignKeys = [
ForeignKey(
entity = JellyfinUser::class,
parentColumns = arrayOf("rowId"),
childColumns = arrayOf("userId"),
onDelete = ForeignKey.CASCADE,
onUpdate = ForeignKey.CASCADE,
),
],
primaryKeys = ["userId", "seriesId"],
)
@Serializable
data class PlaybackLanguageChoice(
val userId: Int,
val seriesId: UUID,
val itemId: UUID? = null,
val audioLanguage: String? = null,
val subtitleLanguage: String? = null,
val subtitlesDisabled: Boolean? = null,
)

View file

@ -659,6 +659,19 @@ sealed interface AppPreference<Pref, T> {
summaryOff = R.string.disabled,
)
val BackdropStylePref =
AppChoicePreference<AppPreferences, BackdropStyle>(
title = R.string.backdrop_display,
defaultValue = BackdropStyle.BACKDROP_DYNAMIC_COLOR,
getter = { it.interfacePreferences.backdropStyle },
setter = { prefs, value ->
prefs.updateInterfacePreferences { backdropStyle = value }
},
displayValues = R.array.backdrop_style_options,
indexToValue = { BackdropStyle.forNumber(it) },
valueToIndex = { it.number },
)
val OneClickPause =
AppSwitchPreference<AppPreferences>(
title = R.string.one_click_pause,
@ -784,6 +797,51 @@ sealed interface AppPreference<Pref, T> {
}
},
)
val LiveTvShowHeader =
AppSwitchPreference<AppPreferences>(
title = R.string.show_details,
defaultValue = true,
getter = { it.interfacePreferences.liveTvPreferences.showHeader },
setter = { prefs, value ->
prefs.updateLiveTvPreferences { showHeader = value }
},
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
val LiveTvFavoriteChannelsBeginning =
AppSwitchPreference<AppPreferences>(
title = R.string.favorite_channels_at_beginning,
defaultValue = true,
getter = { it.interfacePreferences.liveTvPreferences.favoriteChannelsAtBeginning },
setter = { prefs, value ->
prefs.updateLiveTvPreferences { favoriteChannelsAtBeginning = value }
},
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
val LiveTvChannelSortByWatched =
AppSwitchPreference<AppPreferences>(
title = R.string.sort_channels_recently_watched,
defaultValue = false,
getter = { it.interfacePreferences.liveTvPreferences.sortByRecentlyWatched },
setter = { prefs, value ->
prefs.updateLiveTvPreferences { sortByRecentlyWatched = value }
},
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
val LiveTvColorCodePrograms =
AppSwitchPreference<AppPreferences>(
title = R.string.color_code_programs,
defaultValue = true,
getter = { it.interfacePreferences.liveTvPreferences.colorCodePrograms },
setter = { prefs, value ->
prefs.updateLiveTvPreferences { colorCodePrograms = value }
},
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
}
}
@ -864,6 +922,7 @@ val advancedPreferences =
// Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418
// AppPreference.NavDrawerSwitchOnFocus,
AppPreference.ControllerTimeout,
AppPreference.BackdropStylePref,
),
),
)
@ -948,6 +1007,14 @@ val advancedPreferences =
)
}
val liveTvPreferences =
listOf(
AppPreference.LiveTvShowHeader,
AppPreference.LiveTvFavoriteChannelsBeginning,
AppPreference.LiveTvChannelSortByWatched,
AppPreference.LiveTvColorCodePrograms,
)
data class AppSwitchPreference<Pref>(
@get:StringRes override val title: Int,
override val defaultValue: Boolean,

View file

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

View file

@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.update
import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences
import com.github.damontecres.wholphin.preferences.updateMpvOptions
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
@ -173,4 +174,27 @@ suspend fun upgradeApp(
}
}
}
if (previous.isEqualOrBefore(Version.fromString("0.3.5-56-g0"))) {
appPreferences.updateData {
it.updateLiveTvPreferences {
showHeader = AppPreference.LiveTvShowHeader.defaultValue
favoriteChannelsAtBeginning =
AppPreference.LiveTvFavoriteChannelsBeginning.defaultValue
sortByRecentlyWatched =
AppPreference.LiveTvChannelSortByWatched.defaultValue
colorCodePrograms =
AppPreference.LiveTvColorCodePrograms.defaultValue
}
}
}
if (previous.isEqualOrBefore(Version.fromString("0.3.6-52-g0"))) {
if (Build.MODEL.equals("shield android tv", ignoreCase = true)) {
appPreferences.updateData {
it.updateMpvOptions {
useGpuNext = false
}
}
}
}
}

View file

@ -0,0 +1,239 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import android.graphics.Bitmap
import android.util.LruCache
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.core.graphics.drawable.toBitmap
import androidx.datastore.core.DataStore
import androidx.palette.graphics.Palette
import coil3.asDrawable
import coil3.imageLoader
import coil3.request.ImageRequest
import coil3.request.SuccessResult
import coil3.request.allowHardware
import coil3.request.bitmapConfig
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.BackdropStyle
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.ImageType
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
@OptIn(FlowPreview::class)
class BackdropService
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val imageUrlService: ImageUrlService,
private val preferences: DataStore<AppPreferences>,
) {
private val extractedColorCache = LruCache<String, ExtractedColors>(50)
private val _backdropFlow = MutableStateFlow<BackdropResult>(BackdropResult.NONE)
val backdropFlow = _backdropFlow
suspend fun submit(item: BaseItem) =
withContext(Dispatchers.IO) {
val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
if (backdropFlow.firstOrNull()?.imageUrl != imageUrl) {
_backdropFlow.update {
it.copy(
itemId = item.id,
imageUrl = null,
)
}
extractColors(item)
}
}
suspend fun clearBackdrop() {
_backdropFlow.update {
BackdropResult.NONE
}
}
private suspend fun extractColors(item: BaseItem) {
delay(500)
val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
val backdropStyle =
preferences.data
.firstOrNull()
?.interfacePreferences
?.backdropStyle
val dynamicEnabled =
backdropStyle == BackdropStyle.BACKDROP_DYNAMIC_COLOR ||
backdropStyle == BackdropStyle.UNRECOGNIZED
val (primaryColor, secondaryColor, tertiaryColor) =
if (dynamicEnabled) {
extractColorsFromBackdrop(imageUrl)
} else {
ExtractedColors.DEFAULT
}
_backdropFlow.update {
if (it.itemId == item.id) {
BackdropResult(
itemId = item.id,
imageUrl = imageUrl,
primaryColor = primaryColor,
secondaryColor = secondaryColor,
tertiaryColor = tertiaryColor,
)
} else {
it
}
}
}
private suspend fun extractColorsFromBackdrop(imageUrl: String?): ExtractedColors =
withContext(Dispatchers.IO) {
if (imageUrl.isNullOrBlank()) {
return@withContext ExtractedColors.DEFAULT
}
extractedColorCache.get(imageUrl)?.let {
return@withContext it
}
try {
val loader = context.imageLoader
val request =
ImageRequest
.Builder(context)
.data(imageUrl)
.allowHardware(false)
.bitmapConfig(Bitmap.Config.ARGB_8888)
.build()
val result = loader.execute(request)
if (result is SuccessResult) {
val drawable = result.image.asDrawable(context.resources)
val bitmap = drawable.toBitmap(config = Bitmap.Config.ARGB_8888)
extractColorsFromBitmap(bitmap).also {
extractedColorCache.put(imageUrl, it)
}
} else {
ExtractedColors.DEFAULT
}
} catch (e: Exception) {
Timber.e(e, "Error extracting colors from URL: $imageUrl")
ExtractedColors.DEFAULT
}
}
/**
* Helper function to determine if a color is "cool" (blue/purple/green) vs "warm" (red/orange/yellow)
*
* Cool colors have more blue/green than red
*/
private val Palette.Swatch.coolColor: Boolean
get() {
val r = (rgb shr 16) and 0xFF
val g = (rgb shr 8) and 0xFF
val b = rgb and 0xFF
return b > r && (b + g) > (r * 1.5f)
}
private fun toColor(
swatch: Palette.Swatch?,
alpha: Float,
): Color = swatch?.rgb?.let(::Color)?.copy(alpha = alpha) ?: Color.Transparent
/**
* Extracts colors from a bitmap using Android's Palette API.
*
* - Primary (Bottom-Right): darkVibrant -> darkMuted -> default
* - Secondary (Top-Left): Smart selection based on color temperature (prefers cool colors)
* - Tertiary (Top-Right): vibrant -> lightVibrant -> default
*
* @param bitmap The bitmap to extract colors from
* @return ExtractedColors containing primary, secondary, and tertiary colors
*/
private suspend fun extractColorsFromBitmap(bitmap: Bitmap): ExtractedColors =
try {
val palette = Palette.from(bitmap).generate()
val vibrant = palette.vibrantSwatch
val darkVibrant = palette.darkVibrantSwatch
val lightVibrant = palette.lightVibrantSwatch
val muted = palette.mutedSwatch
val darkMuted = palette.darkMutedSwatch
val lightMuted = palette.lightMutedSwatch
val dominant = palette.dominantSwatch
// Primary (Bottom-Right)
val primaryColor = toColor(darkVibrant ?: darkMuted, .4f)
// Secondary (Top-Left): Smart selection based on color properties
// If Vibrant is cool (blue/purple), use it. If Vibrant is warm (yellow/orange) and Muted is cool, use Muted.
// This ensures we get cool tones (blue/purple) for top-left when available
val secondaryColor =
when {
vibrant != null && vibrant.coolColor -> vibrant
muted != null && muted.coolColor -> muted
vibrant != null -> vibrant
muted != null -> muted
else -> null
}.let { toColor(it, .4f) }
// Tertiary (Top-Right under image)
val tertiaryColor = toColor(vibrant ?: lightVibrant, .35f)
Timber.v(
"Colors extracted: primary=%s, secondary=%s, tertiary=%s",
primaryColor,
secondaryColor,
tertiaryColor,
)
ExtractedColors(primaryColor, secondaryColor, tertiaryColor)
} catch (e: Exception) {
Timber.e(e, "Error extracting palette colors")
ExtractedColors.DEFAULT
}
fun clearColorCache() {
extractedColorCache.evictAll()
}
}
data class BackdropResult(
val itemId: UUID?,
val imageUrl: String?,
val primaryColor: Color = Color.Unspecified,
val secondaryColor: Color = Color.Unspecified,
val tertiaryColor: Color = Color.Unspecified,
) {
val hasColors: Boolean =
primaryColor.isSpecified ||
secondaryColor.isSpecified ||
tertiaryColor.isSpecified
companion object {
val NONE =
BackdropResult(
null,
null,
)
}
}
data class ExtractedColors(
val primary: Color,
val secondary: Color,
val tertiary: Color,
) {
companion object {
val DEFAULT = ExtractedColors(Color.Unspecified, Color.Unspecified, Color.Unspecified)
}
}

View file

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

View file

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

View file

@ -0,0 +1,248 @@
package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class StreamChoiceService
@Inject
constructor(
private val serverRepository: ServerRepository,
private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
) {
suspend fun updateAudio(
dto: BaseItemDto,
audioLang: String,
) = update(dto) {
it.copy(
audioLanguage = audioLang,
)
}
suspend fun updateSubtitles(
dto: BaseItemDto,
subtitleLang: String?,
subtitlesDisabled: Boolean,
) = update(dto) {
it.copy(
subtitleLanguage = if (subtitlesDisabled) null else subtitleLang,
subtitlesDisabled = subtitlesDisabled,
)
}
suspend fun update(
dto: BaseItemDto,
update: (PlaybackLanguageChoice) -> PlaybackLanguageChoice,
) {
val seriesId = dto.seriesId
if (seriesId != null) {
val userId = serverRepository.currentUser.value!!.rowId
val currentPlc =
playbackLanguageChoiceDao.get(userId, seriesId)
?: PlaybackLanguageChoice(userId, seriesId, dto.id)
val newPlc = update.invoke(currentPlc)
Timber.v("Saving series PLC: %s", newPlc)
playbackLanguageChoiceDao.save(newPlc)
}
}
suspend fun getPlaybackLanguageChoice(dto: BaseItemDto) =
dto.seriesId?.let {
playbackLanguageChoiceDao.get(serverRepository.currentUser.value!!.rowId, it)
}
/**
* Returns the [MediaSourceInfo] that matched the [ItemPlayback] or else the one with the highest resolution
*/
fun chooseSource(
dto: BaseItemDto,
itemPlayback: ItemPlayback?,
): MediaSourceInfo? =
itemPlayback?.sourceId?.let { dto.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback.sourceId } }
?: chooseSource(dto.mediaSources) // dto.mediaSources?.firstOrNull()
/**
* Returns the [MediaSourceInfo] with the highest video resolution
*/
fun chooseSource(sources: List<MediaSourceInfo>?) =
sources?.letNotEmpty { sources ->
val result =
sources.maxByOrNull { s ->
s.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }?.let { video ->
(video.width ?: 0) * (video.height ?: 0)
} ?: 0
}
result
}
suspend fun chooseAudioStream(
source: MediaSourceInfo,
seriesId: UUID?,
itemPlayback: ItemPlayback?,
plc: PlaybackLanguageChoice?,
prefs: UserPreferences,
): MediaStream? {
val plc = plc ?: seriesId?.let { playbackLanguageChoiceDao.get(serverRepository.currentUser.value!!.rowId, it) }
return source.mediaStreams?.letNotEmpty { streams ->
val candidates = streams.filter { it.type == MediaStreamType.AUDIO }
chooseAudioStream(candidates, itemPlayback, plc, prefs)
}
}
fun chooseAudioStream(
candidates: List<MediaStream>,
itemPlayback: ItemPlayback?,
playbackLanguageChoice: PlaybackLanguageChoice?,
prefs: UserPreferences,
): MediaStream? =
if (itemPlayback?.audioIndexEnabled == true) {
candidates.firstOrNull { it.index == itemPlayback.audioIndex }
} else {
val seriesLang =
playbackLanguageChoice?.audioLanguage?.takeIf { it.isNotNullOrBlank() }
// If the user has chosen a different language for the series, prefer that
val audioLanguage = seriesLang ?: prefs.userConfig.audioLanguagePreference
if (audioLanguage.isNotNullOrBlank()) {
val sorted =
candidates.sortedWith(compareBy<MediaStream> { it.language }.thenByDescending { it.channels })
sorted.firstOrNull { it.language == audioLanguage && it.isDefault }
?: sorted.firstOrNull { it.language == audioLanguage }
?: sorted.firstOrNull { it.isDefault }
?: sorted.firstOrNull()
} else {
candidates.firstOrNull { it.isDefault }
?: candidates.firstOrNull()
}
}
suspend fun chooseSubtitleStream(
source: MediaSourceInfo,
audioStream: MediaStream?,
seriesId: UUID?,
itemPlayback: ItemPlayback?,
plc: PlaybackLanguageChoice?,
prefs: UserPreferences,
): MediaStream? {
val plc =
plc ?: seriesId?.let {
playbackLanguageChoiceDao.get(
serverRepository.currentUser.value!!.rowId,
it,
)
}
return source.mediaStreams?.letNotEmpty { streams ->
val candidates = streams.filter { it.type == MediaStreamType.SUBTITLE }
chooseSubtitleStream(
audioStream,
candidates,
itemPlayback,
plc,
prefs,
)
}
}
fun chooseSubtitleStream(
audioStream: MediaStream?,
candidates: List<MediaStream>,
itemPlayback: ItemPlayback?,
playbackLanguageChoice: PlaybackLanguageChoice?,
prefs: UserPreferences,
): MediaStream? {
if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) {
return null
} else if (itemPlayback?.subtitleIndexEnabled == true) {
return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex }
} else {
val seriesLang =
playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() }
val subtitleLanguage =
(seriesLang ?: prefs.userConfig.subtitleLanguagePreference)
?.takeIf { it.isNotNullOrBlank() }
val subtitleMode =
when {
playbackLanguageChoice?.subtitlesDisabled == false && seriesLang != null -> {
// User has chosen a series level subtitle language, so override their normal
// subtitle mode to display that language
SubtitlePlaybackMode.ALWAYS
}
playbackLanguageChoice?.subtitlesDisabled == true && seriesLang == null -> {
// Series level settings disables subtitles
SubtitlePlaybackMode.NONE
}
else -> {
// Fallback to the user's preference
prefs.userConfig.subtitleMode
}
}
return when (subtitleMode) {
SubtitlePlaybackMode.ALWAYS -> {
if (subtitleLanguage != null) {
candidates.firstOrNull { it.language == subtitleLanguage }
} else {
candidates.firstOrNull()
}
}
SubtitlePlaybackMode.ONLY_FORCED -> {
if (subtitleLanguage != null) {
candidates.firstOrNull { it.language == subtitleLanguage && it.isForced }
} else {
candidates.firstOrNull { it.isForced }
}
}
SubtitlePlaybackMode.SMART -> {
val audioLanguage = prefs.userConfig.audioLanguagePreference
val audioStreamLang = audioStream?.language
if (audioLanguage.isNotNullOrBlank() && audioStreamLang.isNotNullOrBlank() && audioLanguage != audioStreamLang) {
candidates.firstOrNull { it.language == subtitleLanguage }
} else {
null
}
}
SubtitlePlaybackMode.DEFAULT -> {
subtitleLanguage?.let { lang ->
// Find best track that is in the preferred language
(
candidates.firstOrNull { it.isDefault && it.isForced && it.language == lang }
?: candidates.firstOrNull { it.isDefault && it.language == lang }
?: candidates.firstOrNull { it.isForced && it.language == lang }
)
}
?: (
// If none in preferred language, just find the best track
candidates.firstOrNull { it.isDefault && it.isForced }
?: candidates.firstOrNull { it.isDefault }
?: candidates.firstOrNull { it.isForced }
)
}
SubtitlePlaybackMode.NONE -> {
null
}
}
}
}
}

View file

@ -0,0 +1,27 @@
package com.github.damontecres.wholphin.services
import androidx.datastore.core.DataStore
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
import com.github.damontecres.wholphin.preferences.UserPreferences
import kotlinx.coroutines.flow.firstOrNull
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class UserPreferencesService
@Inject
constructor(
private val serverRepository: ServerRepository,
private val preferencesDataStore: DataStore<AppPreferences>,
) {
suspend fun getCurrent(): UserPreferences =
serverRepository.currentUserDto.value!!.configuration.let { userConfig ->
val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
UserPreferences(
appPrefs,
userConfig ?: DefaultUserConfiguration,
)
}
}

View file

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

View file

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

View file

@ -8,8 +8,12 @@ import coil3.compose.setSingletonImageLoaderFactory
import coil3.disk.DiskCache
import coil3.disk.directory
import coil3.memory.MemoryCache
import coil3.network.CacheStrategy
import coil3.network.NetworkRequest
import coil3.network.NetworkResponse
import coil3.network.cachecontrol.CacheControlCacheStrategy
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.Options
import coil3.request.crossfade
import coil3.util.DebugLogger
import okhttp3.OkHttpClient
@ -67,10 +71,39 @@ fun CoilConfig(
.components {
add(
OkHttpNetworkFetcherFactory(
cacheStrategy = { CacheControlCacheStrategy() },
cacheStrategy = { WholphinCacheStrategy(CacheControlCacheStrategy()) },
callFactory = { client },
),
)
}.build()
}
}
/**
* This [CacheStrategy] always prefers the cached response for Trickplay images,
* otherwise the decision is delegated to the provided [CacheStrategy]
*
* The expectation is that Trickplay images will be prefetched so the cache will always be warm
*/
@OptIn(ExperimentalCoilApi::class)
private class WholphinCacheStrategy(
private val delegate: CacheStrategy,
) : CacheStrategy {
override suspend fun read(
cacheResponse: NetworkResponse,
networkRequest: NetworkRequest,
options: Options,
): CacheStrategy.ReadResult =
if (networkRequest.url.contains("/Trickplay/")) {
CacheStrategy.ReadResult(cacheResponse)
} else {
delegate.read(cacheResponse, networkRequest, options)
}
override suspend fun write(
cacheResponse: NetworkResponse?,
networkRequest: NetworkRequest,
networkResponse: NetworkResponse,
options: Options,
): CacheStrategy.WriteResult = delegate.write(cacheResponse, networkRequest, networkResponse, options)
}

View file

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

View file

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

View file

@ -0,0 +1,127 @@
package com.github.damontecres.wholphin.ui.cards
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.crossfade
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.components.Genre
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.setup.rememberIdColor
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import timber.log.Timber
import java.util.UUID
@Composable
fun GenreCard(
genre: Genre?,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val background = rememberIdColor(genre?.id).copy(alpha = .6f)
Card(
modifier =
modifier,
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
colors =
CardDefaults.colors(
containerColor = Color.Transparent,
),
) {
Box(
contentAlignment = Alignment.Center,
modifier =
Modifier
.aspectRatio(AspectRatios.WIDE)
.fillMaxSize()
.clip(RoundedCornerShape(8.dp)),
) {
Timber.v("genre image=${genre?.imageUrl}")
if (genre?.imageUrl.isNotNullOrBlank()) {
AsyncImage(
model =
ImageRequest
.Builder(LocalContext.current)
.data(genre.imageUrl)
.crossfade(true)
.build(),
contentScale = ContentScale.FillBounds,
contentDescription = null,
modifier =
Modifier
.alpha(.6f)
.aspectRatio(AspectRatios.WIDE)
.fillMaxSize(),
)
}
Box(
modifier =
Modifier
.aspectRatio(AspectRatios.WIDE)
.fillMaxSize()
.background(background),
) {
Text(
text = genre?.name ?: "",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier =
Modifier
.padding(16.dp)
.align(Alignment.Center),
)
}
}
}
}
@PreviewTvSpec
@Composable
private fun GenreCardPreview() {
WholphinTheme {
val genre =
Genre(
UUID.randomUUID(),
"Adventure",
null,
Color.Black,
)
GenreCard(
genre = genre,
onClick = {},
onLongClick = {},
modifier = Modifier.width(180.dp),
)
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,154 +0,0 @@
package com.github.damontecres.wholphin.ui.components
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.tv.material3.MaterialTheme
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.transitionFactory
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.CrossFadeFactory
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import kotlinx.coroutines.delay
import org.jellyfin.sdk.model.api.ImageType
import kotlin.time.Duration.Companion.milliseconds
@Composable
fun BoxScope.DetailsBackdropImage(
item: BaseItem?,
modifier: Modifier = Modifier,
) {
val imageUrlService = LocalImageUrlService.current
val backdropImageUrl =
remember(item) {
if (item != null) {
imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
} else {
null
}
}
DetailsBackdropImage(backdropImageUrl, modifier)
}
@Composable
fun BoxScope.DetailsBackdropImage(
backdropImageUrl: String?,
modifier: Modifier = Modifier,
) {
if (backdropImageUrl.isNotNullOrBlank()) {
val gradientColor = MaterialTheme.colorScheme.background
AsyncImage(
model = backdropImageUrl,
contentDescription = null,
contentScale = ContentScale.Fit,
alignment = Alignment.TopEnd,
modifier =
modifier
.align(Alignment.TopEnd)
.fillMaxHeight(.85f)
.alpha(.75f)
.drawWithContent {
drawContent()
drawRect(
Brush.verticalGradient(
colors = listOf(Color.Transparent, gradientColor),
startY = size.height * .5f,
),
)
drawRect(
Brush.horizontalGradient(
colors = listOf(Color.Transparent, gradientColor),
endX = 0f,
startX = size.width * .75f,
),
)
},
)
}
}
@Composable
fun BoxScope.DelayedDetailsBackdropImage(
item: BaseItem?,
modifier: Modifier = Modifier,
) {
val imageUrlService = LocalImageUrlService.current
val backdropImageUrl =
remember(item) {
if (item != null) {
imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
} else {
null
}
}
DelayedDetailsBackdropImage(backdropImageUrl, modifier)
}
/**
* Shows a backdrop image, but with a crossfade & delay
*
* Used for change backdrops when change items frequently
*/
@Composable
fun BoxScope.DelayedDetailsBackdropImage(
focusedBackdropImageUrl: String?,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
var backdropImageUrl by remember { mutableStateOf<String?>(null) }
LaunchedEffect(focusedBackdropImageUrl) {
backdropImageUrl = null
delay(150)
backdropImageUrl = focusedBackdropImageUrl
}
val gradientColor = MaterialTheme.colorScheme.background
AsyncImage(
model =
ImageRequest
.Builder(context)
.data(backdropImageUrl)
.transitionFactory(CrossFadeFactory(250.milliseconds))
.build(),
contentDescription = null,
contentScale = ContentScale.Fit,
alignment = Alignment.TopEnd,
modifier =
modifier
.fillMaxHeight(.7f)
.fillMaxWidth(.7f)
.alpha(.75f)
.align(Alignment.TopEnd)
.drawWithContent {
drawContent()
drawRect(
Brush.verticalGradient(
colors = listOf(Color.Transparent, gradientColor),
startY = size.height * .33f,
),
)
drawRect(
Brush.horizontalGradient(
colors = listOf(gradientColor, Color.Transparent),
startX = 0f,
endX = size.width * .5f,
),
)
},
)
}

View file

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

View file

@ -5,33 +5,43 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicSecureTextField
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.KeyboardActionHandler
import androidx.compose.foundation.text.input.TextFieldDecorator
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TextFieldDefaults.Container
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Icon
import androidx.tv.material3.LocalContentColor
@ -39,6 +49,7 @@ import androidx.tv.material3.MaterialTheme
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.google.protobuf.value
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -191,6 +202,159 @@ fun SearchEditTextBox(
)
}
/**
* A modified [BasicTextField] that looks & fits better with TV material controls
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EditTextBox(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
leadingIcon: @Composable (() -> Unit)? = null,
enabled: Boolean = true,
readOnly: Boolean = false,
height: Dp = 40.dp,
isInputValid: (String) -> Boolean = { true },
supportingText: @Composable (() -> Unit)? = null,
placeholder: @Composable (() -> Unit)? = null,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val colors =
TextFieldDefaults.colors(
unfocusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
focusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f),
disabledTextColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.25f),
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.25f),
errorContainerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.75f),
errorTextColor = MaterialTheme.colorScheme.onErrorContainer,
)
CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) {
BasicTextField(
value = value,
modifier =
modifier
.defaultMinSize(
minWidth = TextFieldDefaults.MinWidth,
minHeight = height,
).height(height),
onValueChange = onValueChange,
enabled = enabled,
readOnly = readOnly,
textStyle = MaterialTheme.typography.bodyLarge.merge(MaterialTheme.colorScheme.onPrimaryContainer),
cursorBrush = SolidColor(colors.cursorColor),
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
interactionSource = interactionSource,
singleLine = true,
maxLines = 1,
minLines = 1,
visualTransformation =
if (keyboardOptions.keyboardType == KeyboardType.Password ||
keyboardOptions.keyboardType == KeyboardType.NumberPassword
) {
PasswordVisualTransformation()
} else {
VisualTransformation.None
},
decorationBox =
@Composable { innerTextField ->
// places leading icon, text field with label and placeholder, trailing icon
TextFieldDefaults.DecorationBox(
value = value,
visualTransformation =
if (keyboardOptions.keyboardType == KeyboardType.Password ||
keyboardOptions.keyboardType == KeyboardType.NumberPassword
) {
PasswordVisualTransformation()
} else {
VisualTransformation.None
},
innerTextField = innerTextField,
placeholder = placeholder,
label = null,
leadingIcon = leadingIcon,
trailingIcon = null,
prefix = null,
suffix = null,
supportingText = supportingText,
shape = CircleShape,
singleLine = true,
enabled = enabled,
isError = false,
interactionSource = interactionSource,
colors = colors,
contentPadding =
PaddingValues(
horizontal = 8.dp,
vertical = 10.dp,
),
container = {
Container(
enabled = enabled,
isError = !isInputValid.invoke(value),
interactionSource = interactionSource,
modifier = Modifier,
colors = colors,
shape = CircleShape,
focusedIndicatorLineThickness = 4.dp,
unfocusedIndicatorLineThickness = 0.dp,
)
},
)
},
)
}
}
/**
* And [EditTextBox] styles for searches
*/
@Composable
fun SearchEditTextBox(
value: String,
onValueChange: (String) -> Unit,
onSearchClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
readOnly: Boolean = false,
height: Dp = 40.dp,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
EditTextBox(
value,
onValueChange,
modifier,
keyboardOptions =
KeyboardOptions(
autoCorrectEnabled = false,
imeAction = ImeAction.Search,
),
keyboardActions =
KeyboardActions(
onSearch = {
onSearchClick.invoke()
this.defaultKeyboardAction(ImeAction.Search)
},
),
leadingIcon = {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.search),
tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
},
enabled = enabled,
readOnly = readOnly,
height = height,
interactionSource = interactionSource,
)
}
@PreviewTvSpec
@Composable
private fun EditTextBoxPreview() {
@ -199,6 +363,10 @@ private fun EditTextBoxPreview() {
EditTextBox(
state = rememberTextFieldState("string"),
)
EditTextBox(
value = "string",
onValueChange = {},
)
SearchEditTextBox(
state = rememberTextFieldState("search query"),
onSearchClick = { },

View file

@ -10,30 +10,43 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.cards.GridCard
import com.github.damontecres.wholphin.ui.cards.GenreCard
import com.github.damontecres.wholphin.ui.detail.CardGrid
import com.github.damontecres.wholphin.ui.detail.CardGridItem
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.request.GetGenresRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
@HiltViewModel
@ -41,11 +54,12 @@ class GenreViewModel
@Inject
constructor(
private val api: ApiClient,
private val imageUrlService: ImageUrlService,
val navigationManager: NavigationManager,
) : ViewModel() {
private lateinit var itemId: UUID
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
val genres = MutableLiveData<List<BaseItem?>>(listOf())
val genres = MutableLiveData<List<Genre>>(listOf())
fun init(itemId: UUID) {
loading.value = LoadingState.Loading
@ -56,11 +70,69 @@ class GenreViewModel
parentId = itemId,
fields = SlimItemFields,
)
val pager = ApiRequestPager(api, request, GetGenresRequestHandler, viewModelScope).init()
val genres =
GetGenresRequestHandler
.execute(api, request)
.content.items
.map {
Genre(it.id, it.name ?: "", null, Color.Black)
}
// val pager = ApiRequestPager(api, request, GetGenresRequestHandler, viewModelScope).init()
withContext(Dispatchers.Main) {
genres.value = pager
this@GenreViewModel.genres.value = genres
loading.value = LoadingState.Success
}
// val excludeItemIds = mutableSetOf<UUID>()
val genreToUrl = ConcurrentHashMap<UUID, String>()
val semaphore = Semaphore(4)
genres
.map { genre ->
viewModelScope.async(Dispatchers.IO) {
semaphore.withPermit {
val item =
GetItemsRequestHandler
.execute(
api,
GetItemsRequest(
// excludeItemIds = excludeItemIds,
parentId = itemId,
recursive = true,
limit = 1,
sortBy = listOf(ItemSortBy.RANDOM),
fields = listOf(ItemFields.GENRES),
imageTypes = listOf(ImageType.THUMB),
imageTypeLimit = 1,
includeItemTypes =
listOf(
BaseItemKind.MOVIE,
BaseItemKind.SERIES,
),
genreIds = listOf(genre.id),
enableTotalRecordCount = false,
),
).content.items
.firstOrNull()
if (item != null) {
// excludeItemIds.add(item.id)
genreToUrl[genre.id] =
imageUrlService.getItemImageUrl(
item.id,
item.type,
null,
false,
ImageType.THUMB,
)
}
}
}
}.awaitAll()
val genresWithImages =
genres.map {
it.copy(
imageUrl = genreToUrl[it.id],
)
}
this@GenreViewModel.genres.setValueOnMain(genresWithImages)
}
}
@ -78,6 +150,16 @@ class GenreViewModel
}
}
data class Genre(
override val id: UUID,
val name: String,
val imageUrl: String?,
val color: Color,
) : CardGridItem {
override val playable: Boolean = false
override val sortName: String get() = name
}
@Composable
fun GenreCardGrid(
itemId: UUID,
@ -117,6 +199,7 @@ fun GenreCardGrid(
)
},
onLongClickItem = { _, _ -> },
onClickPlay = { _, _ -> },
letterPosition = { viewModel.positionOfLetter(it) },
gridFocusRequester = gridFocusRequester,
showJumpButtons = false,
@ -125,13 +208,13 @@ fun GenreCardGrid(
initialPosition = 0,
positionCallback = { columns, position ->
},
cardContent = { item: BaseItem?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier ->
GridCard(
item = item,
columns = 4,
cardContent = { item: Genre?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier ->
GenreCard(
genre = item,
onClick = onClick,
onLongClick = onLongClick,
modifier = mod,
imageAspectRatio = 1f,
)
},
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,28 +1,35 @@
package com.github.damontecres.wholphin.ui.data
import android.content.Context
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.ProvideTextStyle
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.byteRateSuffixes
import com.github.damontecres.wholphin.ui.components.ScrollableDialog
import com.github.damontecres.wholphin.ui.components.formatAudioCodec
import com.github.damontecres.wholphin.ui.components.formatSubtitleCodec
import com.github.damontecres.wholphin.ui.formatBytes
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.util.languageName
import org.jellyfin.sdk.model.api.AudioSpatialFormat
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.VideoRange
import org.jellyfin.sdk.model.api.VideoRangeType
import java.util.Locale
data class ItemDetailsDialogInfo(
@ -37,129 +44,338 @@ fun ItemDetailsDialog(
info: ItemDetailsDialogInfo,
showFilePath: Boolean,
onDismissRequest: () -> Unit,
) = ScrollableDialog(
onDismissRequest = onDismissRequest,
) {
item {
Text(
text = info.title,
style = MaterialTheme.typography.titleLarge,
)
}
if (info.genres.isNotEmpty()) {
val context = LocalContext.current
// Extract stringResource calls outside of ScrollableDialog's non-composable lambda
val pathLabel = stringResource(R.string.path)
val fileSizeLabel = stringResource(R.string.file_size)
val videoLabel = stringResource(R.string.video)
val audioLabel = stringResource(R.string.audio)
val subtitleLabel = stringResource(R.string.subtitle)
val bitrateLabel = stringResource(R.string.bitrate)
val unknown = stringResource(R.string.unknown)
ScrollableDialog(
onDismissRequest = onDismissRequest,
width = 720.dp,
maxHeight = 480.dp,
itemSpacing = 4.dp,
) {
item {
Text(
text = info.genres.joinToString(", "),
style = MaterialTheme.typography.titleSmall,
text = info.title,
style = MaterialTheme.typography.titleLarge,
)
}
}
if (info.overview.isNotNullOrBlank()) {
item {
Text(
text = info.overview,
style = MaterialTheme.typography.bodyLarge,
)
if (info.genres.isNotEmpty()) {
item {
Text(
text = info.genres.joinToString(", "),
style = MaterialTheme.typography.titleSmall,
)
}
}
}
if (info.files.isNotEmpty()) {
item {
Spacer(Modifier.height(12.dp))
if (info.overview.isNotNullOrBlank()) {
item {
Text(
text = info.overview,
style = MaterialTheme.typography.bodyLarge,
)
}
}
}
items(info.files) { file ->
ProvideTextStyle(MaterialTheme.typography.bodyMedium) {
MediaSourceInfo(file, showFilePath, Modifier.padding(top = 12.dp))
// Show detailed media information for the selected source (first one if multiple)
info.files.forEachIndexed { index, source ->
source.mediaStreams?.letNotEmpty { mediaStreams ->
item {
Spacer(Modifier.height(8.dp))
}
// General file information
item {
val containerLabel = stringResource(R.string.container)
MediaInfoSection(
title = stringResource(R.string.general),
items =
buildList {
source.container?.let { add(containerLabel to it) }
if (showFilePath) {
source.path?.let { add(pathLabel to it) }
add("ID" to (source.id ?: unknown))
}
source.size?.let {
add(fileSizeLabel to formatBytes(it))
}
source.bitrate?.let {
add(
bitrateLabel to formatBytes(it, byteRateSuffixes),
)
}
},
)
}
// Video streams
items(mediaStreams.filter { it.type == MediaStreamType.VIDEO }) { stream ->
MediaInfoSection(
title = videoLabel,
items = buildVideoStreamInfo(context, stream),
)
}
// Audio streams - display multiple per row
items(
mediaStreams
.filter { it.type == MediaStreamType.AUDIO }
.chunked(3),
) { streamGroup ->
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
streamGroup.forEach { stream ->
MediaInfoSection(
title = audioLabel,
items = buildAudioStreamInfo(context, stream),
modifier = Modifier.weight(1f),
)
}
// Fill remaining space if less than 3 items
repeat(3 - streamGroup.size) {
Spacer(modifier = Modifier.weight(1f))
}
}
}
// Subtitle streams - display multiple per row
items(
mediaStreams
.filter { it.type == MediaStreamType.SUBTITLE }
.chunked(3),
) { streamGroup ->
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
streamGroup.forEach { stream ->
MediaInfoSection(
title = subtitleLabel,
items = buildSubtitleStreamInfo(context, stream),
modifier = Modifier.weight(1f),
)
}
// Fill remaining space if less than 3 items
repeat(3 - streamGroup.size) {
Spacer(modifier = Modifier.weight(1f))
}
}
}
if (index != info.files.lastIndex) {
item {
Spacer(Modifier.height(8.dp))
}
}
}
}
}
}
@Composable
fun MediaSourceInfo(
source: MediaSourceInfo,
showFilePath: Boolean,
private fun MediaInfoSection(
title: String,
items: List<Pair<String, String>>,
modifier: Modifier = Modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = modifier.padding(vertical = 4.dp),
) {
Text(
text = stringResource(R.string.name) + ": ${source.name}",
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = "ID: ${source.id}",
)
if (showFilePath) {
Text(
text = stringResource(R.string.path) + ": ${source.path}",
)
}
source.size?.let { size ->
Text(
text = stringResource(R.string.file_size) + ": ${formatBytes(size)}",
)
}
source.bitrate?.let { bitrate ->
Text(
text =
stringResource(R.string.bitrate) + ": ${
formatBytes(
bitrate,
byteRateSuffixes,
)
}",
)
}
source.mediaStreams?.letNotEmpty { streams ->
streams.filter { it.type == MediaStreamType.VIDEO }.forEach { stream ->
val data =
buildList {
if (stream.width != null && stream.height != null) {
add("${stream.width}x${stream.height}")
}
stream.averageFrameRate?.let {
add(String.format(Locale.getDefault(), "%.3f", it) + " fps")
}
stream.bitRate?.let { add(formatBytes(it, byteRateSuffixes)) }
stream.codec?.let(::add)
stream.profile?.let(::add)
}
items.forEach { (label, value) ->
Row(
modifier = Modifier.padding(start = 12.dp),
) {
Text(
text = stringResource(R.string.video) + ": " + data.joinToString(" - "),
text = "$label: ",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
)
}
streams.filter { it.type == MediaStreamType.AUDIO }.forEachIndexed { index, stream ->
val data =
buildList {
stream.language?.let { add(languageName(it)) }
stream.codec?.let(::add)
stream.channelLayout?.let(::add)
stream.bitRate?.let { add(formatBytes(it, byteRateSuffixes)) }
if (stream.audioSpatialFormat != AudioSpatialFormat.NONE) add(stream.audioSpatialFormat.serialName)
if (stream.isDefault) add(stringResource(R.string.default_track))
}
Text(
text = stringResource(R.string.audio) + " ${index + 1}: " + data.joinToString(" - "),
)
}
streams.filter { it.type == MediaStreamType.SUBTITLE }.forEachIndexed { index, stream ->
val data =
buildList {
stream.language?.let { add(languageName(it)) }
stream.codec?.let(::add)
if (stream.isDefault) add(stringResource(R.string.default_track))
if (stream.isForced) add(stringResource(R.string.forced_track))
if (stream.isExternal) add(stringResource(R.string.external_track))
}
Text(
text =
stringResource(R.string.subtitle) + " ${index + 1}: " +
data.joinToString(" - "),
text = value,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
private fun buildVideoStreamInfo(
context: Context,
stream: MediaStream,
): List<Pair<String, String>> =
buildList {
val titleLabel = context.getString(R.string.title)
val codecLabel = context.getString(R.string.codec)
val avcLabel = context.getString(R.string.avc)
val profileLabel = context.getString(R.string.profile)
val levelLabel = context.getString(R.string.level)
val resolutionLabel = context.getString(R.string.resolution)
val aspectRatioLabel = context.getString(R.string.aspect_ratio)
val anamorphicLabel = context.getString(R.string.anamorphic)
val interlacedLabel = context.getString(R.string.interlaced)
val framerateLabel = context.getString(R.string.framerate)
val bitrateLabel = context.getString(R.string.bitrate)
val bitDepthLabel = context.getString(R.string.bit_depth)
val videoRangeLabel = context.getString(R.string.video_range)
val videoRangeTypeLabel = context.getString(R.string.video_range_type)
val colorSpaceLabel = context.getString(R.string.color_space)
val colorTransferLabel = context.getString(R.string.color_transfer)
val colorPrimariesLabel = context.getString(R.string.color_primaries)
val pixelFormatLabel = context.getString(R.string.pixel_format)
val refFramesLabel = context.getString(R.string.ref_frames)
val nalLabel = context.getString(R.string.nal)
val yesStr = context.getString(R.string.yes)
val noStr = context.getString(R.string.no)
val sdrStr = context.getString(R.string.sdr)
val hdrStr = context.getString(R.string.hdr)
val hdr10Str = context.getString(R.string.hdr10)
val hdr10PlusStr = context.getString(R.string.hdr10_plus)
val hlgStr = context.getString(R.string.hlg)
val bitUnit = context.getString(R.string.bit_unit)
stream.title?.let { add(titleLabel to it) }
stream.codec?.let { add(codecLabel to it.uppercase()) }
stream.isAvc?.let { add(avcLabel to if (it) yesStr else noStr) }
stream.profile?.let { add(profileLabel to it) }
stream.level?.let { add(levelLabel to it.toString()) }
if (stream.width != null && stream.height != null) {
add(resolutionLabel to "${stream.width}x${stream.height}")
}
if (stream.width != null && stream.height != null) {
val aspectRatio = calculateAspectRatio(stream.width!!, stream.height!!)
add(aspectRatioLabel to aspectRatio)
}
stream.isAnamorphic?.let { add(anamorphicLabel to if (it) yesStr else noStr) }
stream.isInterlaced?.let { add(interlacedLabel to if (it) yesStr else noStr) }
stream.averageFrameRate?.let {
add(framerateLabel to String.format(Locale.getDefault(), "%.3f", it))
}
stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) }
stream.bitDepth?.let { add(bitDepthLabel to "$it $bitUnit") }
stream.videoRange?.let {
val rangeStr =
when (it) {
VideoRange.SDR -> sdrStr
VideoRange.HDR -> hdrStr
VideoRange.UNKNOWN -> null
else -> null
}
rangeStr?.let { add(videoRangeLabel to it) }
}
stream.videoRangeType?.let {
val rangeTypeStr =
when (it) {
VideoRangeType.SDR -> sdrStr
VideoRangeType.HDR10 -> hdr10Str
VideoRangeType.HDR10_PLUS -> hdr10PlusStr
VideoRangeType.HLG -> hlgStr
VideoRangeType.DOVI,
VideoRangeType.DOVI_WITH_HDR10,
VideoRangeType.DOVI_WITH_HLG,
VideoRangeType.DOVI_WITH_SDR,
-> context.getString(R.string.dolby_vision)
VideoRangeType.UNKNOWN -> null
else -> null
}
rangeTypeStr?.let { add(videoRangeTypeLabel to it) }
}
stream.colorSpace?.let { add(colorSpaceLabel to it) }
stream.colorTransfer?.let { add(colorTransferLabel to it) }
stream.colorPrimaries?.let { add(colorPrimariesLabel to it) }
stream.pixelFormat?.let { add(pixelFormatLabel to it) }
stream.refFrames?.let { add(refFramesLabel to it.toString()) }
stream.nalLengthSize?.let { add(nalLabel to it.toString()) }
}
private fun buildAudioStreamInfo(
context: Context,
stream: MediaStream,
): List<Pair<String, String>> =
buildList {
val titleLabel = context.getString(R.string.title)
val languageLabel = context.getString(R.string.language)
val codecLabel = context.getString(R.string.codec)
val layoutLabel = context.getString(R.string.layout)
val channelsLabel = context.getString(R.string.channels)
val bitrateLabel = context.getString(R.string.bitrate)
val sampleRateLabel = context.getString(R.string.sample_rate)
val defaultLabel = context.getString(R.string.default_track)
val externalLabel = context.getString(R.string.external_track)
val yesStr = context.getString(R.string.yes)
val noStr = context.getString(R.string.no)
val sampleRateUnit = context.getString(R.string.sample_rate_unit)
stream.title?.let { add(titleLabel to it) }
stream.language?.let { add(languageLabel to languageName(it)) }
stream.codec?.let {
val formattedCodec = formatAudioCodec(context, it, stream.profile) ?: it.uppercase()
add(codecLabel to formattedCodec)
}
stream.channelLayout?.let { add(layoutLabel to it) }
stream.channels?.let { add(channelsLabel to it.toString()) }
stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) }
stream.sampleRate?.let { add(sampleRateLabel to "$it $sampleRateUnit") }
stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) }
}
private fun buildSubtitleStreamInfo(
context: Context,
stream: MediaStream,
): List<Pair<String, String>> =
buildList {
val titleLabel = context.getString(R.string.title)
val languageLabel = context.getString(R.string.language)
val codecLabel = context.getString(R.string.codec)
val avcLabel = context.getString(R.string.avc)
val defaultLabel = context.getString(R.string.default_track)
val forcedLabel = context.getString(R.string.forced_track)
val externalLabel = context.getString(R.string.external_track)
val yesStr = context.getString(R.string.yes)
val noStr = context.getString(R.string.no)
stream.title?.let { add(titleLabel to it) }
stream.language?.let { add(languageLabel to languageName(it)) }
stream.codec?.let {
val formattedCodec = formatSubtitleCodec(it) ?: it.uppercase()
add(codecLabel to formattedCodec)
}
stream.isAvc?.let { add(avcLabel to if (it) yesStr else noStr) }
stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) }
stream.isForced?.let { add(forcedLabel to if (it) yesStr else noStr) }
stream.isExternal?.let { add(externalLabel to if (it) yesStr else noStr) }
}
private fun calculateAspectRatio(
width: Int,
height: Int,
): String {
val gcd = gcd(width, height)
val w = width / gcd
val h = height / gcd
return "$w:$h"
}
private fun gcd(
a: Int,
b: Int,
): Int = if (b == 0) a else gcd(b, a % b)

View file

@ -22,6 +22,7 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.layout.LazyLayoutCacheWindow
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -33,11 +34,14 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
@ -49,6 +53,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Button
import androidx.tv.material3.ButtonDefaults
import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
@ -57,8 +63,6 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.cards.GridCard
import com.github.damontecres.wholphin.ui.components.Button
import com.github.damontecres.wholphin.ui.components.TextButton
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.playback.isBackwardButton
import com.github.damontecres.wholphin.ui.playback.isForwardButton
@ -69,15 +73,23 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.util.UUID
private const val DEBUG = false
interface CardGridItem {
val id: UUID
val playable: Boolean
val sortName: String
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun CardGrid(
pager: List<BaseItem?>,
onClickItem: (Int, BaseItem) -> Unit,
onLongClickItem: (Int, BaseItem) -> Unit,
fun <T : CardGridItem> CardGrid(
pager: List<T?>,
onClickItem: (Int, T) -> Unit,
onLongClickItem: (Int, T) -> Unit,
onClickPlay: (Int, T) -> Unit,
letterPosition: suspend (Char) -> Int,
gridFocusRequester: FocusRequester,
showJumpButtons: Boolean,
@ -86,13 +98,13 @@ fun CardGrid(
initialPosition: Int = 0,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
cardContent: @Composable (
item: BaseItem?,
item: T?,
onClick: () -> Unit,
onLongClick: () -> Unit,
mod: Modifier,
) -> Unit = { item, onClick, onLongClick, mod ->
GridCard(
item = item,
item = item as BaseItem?,
onClick = onClick,
onLongClick = onLongClick,
imageContentScale = ContentScale.FillBounds,
@ -213,7 +225,11 @@ fun CardGrid(
jumpToTop()
return@onKeyEvent true
} else if (isPlayKeyUp(it)) {
// TODO play the focused item
val item = pager.getOrNull(focusedIndex)
if (item?.playable == true) {
Timber.v("Clicked play on ${item.id}")
onClickPlay.invoke(focusedIndex, item)
}
return@onKeyEvent true
} else if (useJumpRemoteButtons && isForwardButton(it)) {
jump(jump1)
@ -341,7 +357,6 @@ fun CardGrid(
remember(focusedIndex) {
pager
.getOrNull(focusedIndex)
?.data
?.sortName
?.first()
?.uppercaseChar()
@ -360,7 +375,11 @@ fun CardGrid(
AlphabetButtons(
letters = letters,
currentLetter = currentLetter,
modifier = Modifier.align(Alignment.CenterVertically),
modifier =
Modifier
.align(Alignment.CenterVertically)
.padding(end = 16.dp),
// Add end padding to push away from edge
letterClicked = { letter ->
scope.launch(ExceptionHandler()) {
val jumpPosition =
@ -369,6 +388,7 @@ fun CardGrid(
}
Timber.d("Alphabet jump to $jumpPosition")
if (jumpPosition >= 0) {
pager.getOrNull(jumpPosition)
gridState.scrollToItem(jumpPosition)
focusOn(jumpPosition)
alphabetFocus = true
@ -422,7 +442,6 @@ fun AlphabetButtons(
letterClicked: (Char) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
@ -434,51 +453,97 @@ fun AlphabetButtons(
listState.layoutInfo.visibleItemsInfo
.lastOrNull()
?.index ?: -1
if (index < firstVisibleItemIndex || index > lastVisibleItemIndex) {
if (index !in firstVisibleItemIndex..lastVisibleItemIndex) {
listState.animateScrollToItem(index)
}
}
}
// Focus & interaction states for each letter button
val focusRequesters = remember { List(letters.length) { FocusRequester() } }
val interactionSources = remember { List(letters.length) { MutableInteractionSource() } }
// Track if the entire alphabet picker component has focus
var alphabetPickerFocused by remember { mutableStateOf(false) }
LazyColumn(
contentPadding = PaddingValues(4.dp),
contentPadding = PaddingValues(vertical = 1.1.dp, horizontal = 2.dp),
verticalArrangement = Arrangement.spacedBy(1.1.dp),
state = listState,
modifier =
modifier.focusProperties {
onEnter = {
focusRequesters[index.coerceIn(0, letters.length - 1)].tryRequestFocus()
}
},
modifier
.onFocusChanged { focusState ->
alphabetPickerFocused = focusState.hasFocus
}.focusProperties {
onEnter = {
focusRequesters[index.coerceIn(0, letters.length - 1)].tryRequestFocus()
}
},
) {
items(
letters.length,
key = { letters[it] },
) { index ->
val interactionSource = remember { MutableInteractionSource() }
val interactionSource = interactionSources[index]
val focused by interactionSource.collectIsFocusedAsState()
TextButton(
val isCurrentLetter = letters[index] == currentLetter
// Apply alpha to individual items, but keep selected letter fully visible when picker is unfocused
val itemAlpha =
when {
isCurrentLetter && !alphabetPickerFocused -> 1f
alphabetPickerFocused -> .85f
else -> .25f
}
// Only show circle background for the current letter (or when focused)
// Wrap in Box with clipping to prevent focus indicator from overflowing
Box(
modifier =
Modifier
.size(24.dp)
.focusRequester(focusRequesters[index]),
contentPadding = PaddingValues(2.dp),
interactionSource = interactionSource,
onClick = {
letterClicked.invoke(letters[index])
},
.size(14.dp)
.clip(CircleShape)
.alpha(itemAlpha),
) {
val color =
if (!focused && letters[index] == currentLetter) {
MaterialTheme.colorScheme.tertiary
} else {
LocalContentColor.current
}
Text(
text = letters[index].toString(),
color = color,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
Button(
modifier =
Modifier
.size(14.dp)
.focusRequester(focusRequesters[index]),
contentPadding = PaddingValues(0.dp), // No padding to maximize text space
interactionSource = interactionSource,
onClick = {
letterClicked.invoke(letters[index])
},
colors =
if (isCurrentLetter || focused) {
// Use default button colors for current letter or focused
ButtonDefaults.colors()
} else {
// Transparent background for non-current letters (no circle)
ButtonDefaults.colors(
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onSurface,
focusedContainerColor = MaterialTheme.colorScheme.primaryContainer,
focusedContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
)
},
) {
// Use border color for selected letter when focused, tertiary for unfocused-selected
val color =
when {
isCurrentLetter && focused -> MaterialTheme.colorScheme.border
isCurrentLetter -> MaterialTheme.colorScheme.tertiary
focused -> LocalContentColor.current
else -> MaterialTheme.colorScheme.onSurface
}
Text(
text = letters[index].toString(),
color = color,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}

View file

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

View file

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

View file

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

View file

@ -36,6 +36,7 @@ import com.github.damontecres.wholphin.data.ItemPlaybackDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.RefreshRateService
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.ExceptionHandler
@ -58,6 +59,9 @@ class DebugViewModel
constructor(
val serverRepository: ServerRepository,
val itemPlaybackDao: ItemPlaybackDao,
val refreshRateService: RefreshRateService,
val clientInfo: ClientInfo,
val deviceInfo: DeviceInfo,
) : ViewModel() {
val itemPlaybacks = MutableLiveData<List<ItemPlayback>>(listOf())
val logcat = MutableLiveData<List<LogcatLine>>(listOf())
@ -220,6 +224,7 @@ fun DebugPage(
listOf(
"Version Name: ${pkgInfo.versionName}",
"Version Code: ${pkgInfo.versionCodeLong}",
"ClientInfo: ${viewModel.clientInfo}",
"Build type: ${BuildConfig.BUILD_TYPE}",
"Debug enabled: ${BuildConfig.DEBUG}",
"ABIs: ${Build.SUPPORTED_ABIS.toList()}",
@ -233,6 +238,32 @@ fun DebugPage(
}
}
}
item {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "Device Information",
style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.onSurface,
)
listOf(
"DeviceInfo: ${viewModel.deviceInfo}",
"Manufacturer: ${Build.MANUFACTURER}",
"Model: ${Build.MODEL}",
"Display Modes:",
*viewModel.refreshRateService.displayModes,
).forEach {
Text(
text = it.toString(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
item {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),

View file

@ -4,6 +4,7 @@ import android.content.Context
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.ui.graphics.Color
@ -43,6 +44,7 @@ data class MoreDialogActions(
* @param navigateTo a function to trigger a navigation
* @param onChooseVersion callback to pick a version of the item
* @param onChooseTracks callback to pick a track for the given type of the item
* @param onShowOverview callback to show overview dialog with media information
*/
fun buildMoreDialogItems(
context: Context,
@ -54,6 +56,7 @@ fun buildMoreDialogItems(
actions: MoreDialogActions,
onChooseVersion: () -> Unit,
onChooseTracks: (MediaStreamType) -> Unit,
onShowOverview: () -> Unit,
): List<DialogItem> =
buildList {
add(
@ -159,6 +162,16 @@ fun buildMoreDialogItems(
},
)
}
if (item.data.mediaSources?.isNotEmpty() == true) {
add(
DialogItem(
context.getString(R.string.media_information),
Icons.Default.Info,
) {
onShowOverview.invoke()
},
)
}
add(
DialogItem(
context.getString(R.string.play_with_transcoding),

View file

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

View file

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

View file

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

View file

@ -30,9 +30,7 @@ import androidx.lifecycle.compose.LifecycleStartEffect
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.chooseSource
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.DetailsBackdropImage
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage
@ -57,6 +55,7 @@ import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUID
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID
import kotlin.time.Duration
@ -157,7 +156,7 @@ fun EpisodeDetails(
watched = ep.data.userData?.played ?: false,
favorite = ep.data.userData?.isFavorite ?: false,
seriesId = ep.data.seriesId,
sourceId = chosenStreams?.sourceId,
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
actions = moreActions,
onChooseVersion = {
chooseVersion =
@ -174,23 +173,36 @@ fun EpisodeDetails(
moreDialog = null
},
onChooseTracks = { type ->
chooseSource(
ep.data,
chosenStreams?.itemPlayback,
)?.let { source ->
chooseVersion =
chooseStream(
context = context,
streams = source.mediaStreams.orEmpty(),
type = type,
onClick = { trackIndex ->
viewModel.saveTrackSelection(
ep,
chosenStreams?.itemPlayback,
trackIndex,
type,
)
},
viewModel.streamChoiceService
.chooseSource(
ep.data,
chosenStreams?.itemPlayback,
)?.let { source ->
chooseVersion =
chooseStream(
context = context,
streams = source.mediaStreams.orEmpty(),
type = type,
onClick = { trackIndex ->
viewModel.saveTrackSelection(
ep,
chosenStreams?.itemPlayback,
trackIndex,
type,
)
},
)
}
},
onShowOverview = {
val source = chosenStreams?.source ?: ep.data.mediaSources?.firstOrNull()
if (source != null) {
overviewDialog =
ItemDetailsDialogInfo(
title = ep.name ?: context.getString(R.string.unknown),
overview = ep.data.overview,
genres = ep.data.genres.orEmpty(),
files = listOf(source),
)
}
},
@ -283,7 +295,6 @@ fun EpisodeDetailsContent(
focusRequesters.getOrNull(position)?.tryRequestFocus()
}
Box(modifier = modifier) {
DetailsBackdropImage(ep)
LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp),

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,94 @@
package com.github.damontecres.wholphin.ui.detail.livetv
import android.view.Gravity
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.liveTvPreferences
import com.github.damontecres.wholphin.ui.preferences.ComposablePreference
import com.github.damontecres.wholphin.ui.tryRequestFocus
@Composable
fun LiveTvViewOptionsDialog(
preferences: AppPreferences,
onDismissRequest: () -> Unit,
onViewOptionsChange: (AppPreferences) -> Unit,
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
val columnState = rememberLazyListState()
Dialog(
onDismissRequest = onDismissRequest,
properties =
DialogProperties(
usePlatformDefaultWidth = false,
),
) {
val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider
dialogWindowProvider?.window?.let { window ->
window.setGravity(Gravity.END)
window.setDimAmount(0f)
}
LazyColumn(
state = columnState,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
.width(256.dp)
.heightIn(max = 380.dp)
.focusRequester(focusRequester)
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp),
shape = RoundedCornerShape(8.dp),
),
) {
stickyHeader {
Text(
text = stringResource(R.string.view_options),
style = MaterialTheme.typography.titleMedium,
)
}
items(liveTvPreferences) { pref ->
pref as AppPreference<AppPreferences, Any>
val interactionSource = remember { MutableInteractionSource() }
val value = pref.getter.invoke(preferences)
ComposablePreference(
preference = pref,
value = value,
onNavigate = {},
onValueChange = { newValue ->
onViewOptionsChange.invoke(pref.setter(preferences, newValue))
},
interactionSource = interactionSource,
modifier = Modifier,
)
}
}
}
}

View file

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

View file

@ -0,0 +1,122 @@
package com.github.damontecres.wholphin.ui.detail.livetv
import android.text.format.DateUtils
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.components.DotSeparatedRow
import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.StreamLabel
import com.github.damontecres.wholphin.ui.roundMinutes
import java.time.LocalDateTime
import java.time.ZoneId
import kotlin.time.toKotlinDuration
@Composable
fun TvGuideHeader(
program: TvProgram?,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val now = LocalDateTime.now()
val details =
remember(program) {
buildList {
program?.let {
val differentDay = it.start.toLocalDate() != now.toLocalDate()
val time =
DateUtils.formatDateRange(
context,
it.start
.atZone(ZoneId.systemDefault())
.toInstant()
.epochSecond * 1000,
it.end
.atZone(ZoneId.systemDefault())
.toInstant()
.epochSecond * 1000,
DateUtils.FORMAT_SHOW_TIME or if (differentDay) DateUtils.FORMAT_SHOW_WEEKDAY else 0,
)
add(time)
}
if (program?.isFake == false) {
program
.duration
.roundMinutes
.toString()
.let(::add)
if (now.isAfter(program.start) && now.isBefore(program.end)) {
java.time.Duration
.between(now, program.end)
.toKotlinDuration()
.roundMinutes
.let { add("$it left") }
}
program.seasonEpisode?.let { "S${it.season} E${it.episode}" }?.let(::add)
program.officialRating?.let(::add)
}
}
}
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalAlignment = Alignment.Start,
modifier = modifier,
) {
Text(
text = program?.name ?: program?.id.toString(),
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(.75f),
)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(.6f),
) {
program?.subtitle?.let {
Text(
text = program.subtitle,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
DotSeparatedRow(
texts = details,
communityRating = null,
criticRating = null,
textStyle = MaterialTheme.typography.titleSmall,
modifier = Modifier,
)
if (program?.isRepeat == true) {
StreamLabel(stringResource(R.string.live_tv_repeat))
}
}
OverviewText(
overview = program?.overview ?: "",
maxLines = 3,
onClick = {},
enabled = false,
)
}
}
}

View file

@ -45,7 +45,6 @@ import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.RemoteTrailer
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.data.model.aspectRatioFloat
import com.github.damontecres.wholphin.data.model.chooseSource
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.ui.AspectRatios
@ -55,7 +54,6 @@ import com.github.damontecres.wholphin.ui.cards.ExtrasRow
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.cards.PersonRow
import com.github.damontecres.wholphin.ui.cards.SeasonCard
import com.github.damontecres.wholphin.ui.components.DetailsBackdropImage
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage
@ -83,6 +81,7 @@ import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUID
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID
import kotlin.time.Duration
@ -204,7 +203,7 @@ fun MovieDetails(
watched = movie.data.userData?.played ?: false,
favorite = movie.data.userData?.isFavorite ?: false,
seriesId = null,
sourceId = chosenStreams?.sourceId,
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
actions = moreActions,
onChooseVersion = {
chooseVersion =
@ -221,25 +220,37 @@ fun MovieDetails(
moreDialog = null
},
onChooseTracks = { type ->
chooseSource(
movie.data,
chosenStreams?.itemPlayback,
)?.let { source ->
chooseVersion =
chooseStream(
context = context,
streams = source.mediaStreams.orEmpty(),
type = type,
onClick = { trackIndex ->
viewModel.saveTrackSelection(
movie,
chosenStreams?.itemPlayback,
trackIndex,
type,
)
},
)
}
viewModel.streamChoiceService
.chooseSource(
movie.data,
chosenStreams?.itemPlayback,
)?.let { source ->
chooseVersion =
chooseStream(
context = context,
streams = source.mediaStreams.orEmpty(),
type = type,
onClick = { trackIndex ->
viewModel.saveTrackSelection(
movie,
chosenStreams?.itemPlayback,
trackIndex,
type,
)
},
)
}
},
onShowOverview = {
overviewDialog =
ItemDetailsDialogInfo(
title =
movie.name
?: context.getString(R.string.unknown),
overview = movie.data.overview,
genres = movie.data.genres.orEmpty(),
files = movie.data.mediaSources.orEmpty(),
)
},
),
)
@ -384,7 +395,6 @@ fun MovieDetailsContent(
focusRequesters.getOrNull(position)?.tryRequestFocus()
}
Box(modifier = modifier) {
DetailsBackdropImage(movie)
LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,17 +1,59 @@
package com.github.damontecres.wholphin.ui.nav
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import androidx.tv.material3.MaterialTheme
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.transitionFactory
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.BackdropStyle
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.CrossFadeFactory
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.launchIO
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
@HiltViewModel
class ApplicationContentViewModel
@Inject
constructor(
val backdropService: BackdropService,
) : ViewModel() {
fun clearBackdrop() {
viewModelScope.launchIO { backdropService.clearBackdrop() }
}
}
/**
* This is generally the root composable of the of the app
@ -25,37 +67,158 @@ fun ApplicationContent(
navigationManager: NavigationManager,
preferences: UserPreferences,
modifier: Modifier = Modifier,
viewModel: ApplicationContentViewModel = hiltViewModel(),
) {
NavDisplay(
backStack = navigationManager.backStack,
onBack = { navigationManager.goBack() },
entryDecorators =
listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
entryProvider = { key ->
key as Destination
val contentKey = "${key}_${server?.id}_${user?.id}"
NavEntry(key, contentKey = contentKey) {
if (key.fullScreen) {
DestinationContent(
destination = key,
preferences = preferences,
modifier = modifier.fillMaxSize(),
)
} else if (user != null && server != null) {
NavDrawer(
destination = key,
preferences = preferences,
user = user,
server = server,
modifier = modifier,
)
} else {
ErrorMessage("Trying to go to $key without a user logged in", null)
}
val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle()
val backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle
Box(
modifier = modifier,
) {
val baseBackgroundColor = MaterialTheme.colorScheme.background
if (backdrop.hasColors &&
(backdropStyle == BackdropStyle.BACKDROP_DYNAMIC_COLOR || backdropStyle == BackdropStyle.UNRECOGNIZED)
) {
val animPrimary by animateColorAsState(
backdrop.primaryColor,
animationSpec = tween(1250),
label = "dynamic_backdrop_primary",
)
val animSecondary by animateColorAsState(
backdrop.secondaryColor,
animationSpec = tween(1250),
label = "dynamic_backdrop_secondary",
)
val animTertiary by animateColorAsState(
backdrop.tertiaryColor,
animationSpec = tween(1250),
label = "dynamic_backdrop_tertiary",
)
Box(
modifier =
Modifier
.fillMaxSize()
.drawBehind {
drawRect(color = baseBackgroundColor)
// Top Left (Vibrant/Muted)
drawRect(
brush =
Brush.radialGradient(
colors = listOf(animSecondary, Color.Transparent),
center = Offset(0f, 0f),
radius = size.width * 0.8f,
),
)
// Bottom Right (DarkVibrant/DarkMuted)
drawRect(
brush =
Brush.radialGradient(
colors = listOf(animPrimary, Color.Transparent),
center = Offset(size.width, size.height),
radius = size.width * 0.8f,
),
)
// Bottom Left (Dark / Bridge)
drawRect(
brush =
Brush.radialGradient(
colors =
listOf(
baseBackgroundColor,
Color.Transparent,
),
center = Offset(0f, size.height),
radius = size.width * 0.8f,
),
)
// Top Right (Under Image - Vibrant/Bright)
drawRect(
brush =
Brush.radialGradient(
colors = listOf(animTertiary, Color.Transparent),
center = Offset(size.width, 0f),
radius = size.width * 0.8f,
),
)
},
)
}
if (backdropStyle != BackdropStyle.BACKDROP_NONE) {
Box(
modifier = Modifier.fillMaxSize(),
) {
AsyncImage(
model =
ImageRequest
.Builder(LocalContext.current)
.data(backdrop.imageUrl)
.transitionFactory(CrossFadeFactory(800.milliseconds))
.build(),
contentDescription = null,
contentScale = ContentScale.Fit,
alignment = Alignment.TopEnd,
modifier =
Modifier
.align(Alignment.TopEnd)
.fillMaxHeight(.7f)
.fillMaxWidth(.7f)
.alpha(.95f)
.drawWithContent {
drawContent()
drawRect(
brush =
Brush.horizontalGradient(
colors = listOf(Color.Transparent, Color.Black),
startX = 0f,
endX = size.width * 0.6f,
),
blendMode = BlendMode.DstIn,
)
drawRect(
brush =
Brush.verticalGradient(
colors = listOf(Color.Black, Color.Transparent),
startY = 0f,
endY = size.height,
),
blendMode = BlendMode.DstIn,
)
},
)
}
},
)
}
NavDisplay(
backStack = navigationManager.backStack,
onBack = { navigationManager.goBack() },
entryDecorators =
listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
entryProvider = { key ->
key as Destination
val contentKey = "${key}_${server?.id}_${user?.id}"
NavEntry(key, contentKey = contentKey) {
if (key.fullScreen) {
DestinationContent(
destination = key,
preferences = preferences,
onClearBackdrop = viewModel::clearBackdrop,
modifier = Modifier.fillMaxSize(),
)
} else if (user != null && server != null) {
NavDrawer(
destination = key,
preferences = preferences,
user = user,
server = server,
onClearBackdrop = viewModel::clearBackdrop,
modifier = Modifier.fillMaxSize(),
)
} else {
ErrorMessage("Trying to go to $key without a user logged in", null)
}
}
},
)
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,13 +1,26 @@
package com.github.damontecres.wholphin.ui.playback
import android.view.Gravity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import timber.log.Timber
import kotlin.time.Duration
enum class PlaybackDialogType {
MORE,
@ -16,6 +29,7 @@ enum class PlaybackDialogType {
AUDIO,
PLAYBACK_SPEED,
VIDEO_SCALE,
SUBTITLE_DELAY,
}
data class PlaybackSettings(
@ -26,16 +40,19 @@ data class PlaybackSettings(
val subtitleStreams: List<SubtitleStream>,
val playbackSpeed: Float,
val contentScale: ContentScale,
val subtitleDelay: Duration,
)
@Composable
fun PlaybackDialog(
enableSubtitleDelay: Boolean,
type: PlaybackDialogType,
settings: PlaybackSettings,
onDismissRequest: () -> Unit,
onControllerInteraction: () -> Unit,
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
onPlaybackActionClick: (PlaybackAction) -> Unit,
onChangeSubtitleDelay: (Duration) -> Unit,
) {
when (type) {
PlaybackDialogType.MORE -> {
@ -97,11 +114,14 @@ fun PlaybackDialog(
PlaybackDialogType.SETTINGS -> {
val options =
listOf(
stringResource(R.string.audio),
stringResource(R.string.playback_speed),
stringResource(R.string.video_scale),
)
buildList {
add(stringResource(R.string.audio))
add(stringResource(R.string.playback_speed))
add(stringResource(R.string.video_scale))
if (enableSubtitleDelay) {
add(stringResource(R.string.subtitle_delay))
}
}
BottomDialog(
choices = options,
currentChoice = null,
@ -111,6 +131,7 @@ fun PlaybackDialog(
0 -> onClickPlaybackDialogType(PlaybackDialogType.AUDIO)
1 -> onClickPlaybackDialogType(PlaybackDialogType.PLAYBACK_SPEED)
2 -> onClickPlaybackDialogType(PlaybackDialogType.VIDEO_SCALE)
3 -> onClickPlaybackDialogType(PlaybackDialogType.SUBTITLE_DELAY)
}
},
gravity = Gravity.END,
@ -173,5 +194,31 @@ fun PlaybackDialog(
gravity = Gravity.END,
)
}
PlaybackDialogType.SUBTITLE_DELAY -> {
Dialog(
onDismissRequest = onDismissRequest,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider
dialogWindowProvider?.window?.setDimAmount(0f)
Box(
modifier =
Modifier
.wrapContentSize()
.background(
AppColors.TransparentBlack50,
shape = RoundedCornerShape(16.dp),
),
) {
SubtitleDelay(
delay = settings.subtitleDelay,
onChangeDelay = onChangeSubtitleDelay,
modifier = Modifier.padding(8.dp),
)
}
}
}
}
}

View file

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

View file

@ -79,11 +79,13 @@ import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.Media3SubtitleOverride
import com.github.damontecres.wholphin.util.mpv.MpvPlayer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.extensions.ticks
import java.util.UUID
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@ -163,6 +165,11 @@ fun PlaybackPage(
var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) }
val subtitleDelay = currentPlayback?.subtitleDelay ?: Duration.ZERO
LaunchedEffect(subtitleDelay) {
(player as? MpvPlayer)?.subtitleDelay = subtitleDelay
}
val presentationState = rememberPresentationState(player, false)
val scaledModifier =
Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp)
@ -325,37 +332,6 @@ fun PlaybackPage(
}
}
// Subtitles
if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) {
val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f)
AndroidView(
factory = { context ->
SubtitleView(context).apply {
preferences.appPreferences.interfacePreferences.subtitlesPreferences.let {
setStyle(it.toSubtitleStyle())
setFixedTextSize(Dimension.SP, it.fontSize.toFloat())
setBottomPaddingFraction(it.margin.toFloat() / 100f)
}
}
},
update = {
it.setCues(cues)
Media3SubtitleOverride(
preferences.appPreferences.interfacePreferences.subtitlesPreferences
.calculateEdgeSize(density),
).apply(it)
},
onReset = {
it.setCues(null)
},
modifier =
Modifier
.fillMaxSize(maxSize)
.align(Alignment.TopCenter)
.background(Color.Transparent),
)
}
// The playback controls
AnimatedVisibility(
controllerViewState.controlsVisible,
@ -395,6 +371,37 @@ fun PlaybackPage(
showClock = preferences.appPreferences.interfacePreferences.showClock,
)
}
// Subtitles
if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) {
val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f)
AndroidView(
factory = { context ->
SubtitleView(context).apply {
preferences.appPreferences.interfacePreferences.subtitlesPreferences.let {
setStyle(it.toSubtitleStyle())
setFixedTextSize(Dimension.SP, it.fontSize.toFloat())
setBottomPaddingFraction(it.margin.toFloat() / 100f)
}
}
},
update = {
it.setCues(cues)
Media3SubtitleOverride(
preferences.appPreferences.interfacePreferences.subtitlesPreferences
.calculateEdgeSize(density),
).apply(it)
},
onReset = {
it.setCues(null)
},
modifier =
Modifier
.fillMaxSize(maxSize)
.align(Alignment.TopCenter)
.background(Color.Transparent),
)
}
}
// Ask to skip intros, etc button
@ -473,9 +480,7 @@ fun PlaybackPage(
).joinToString(" - "),
description = it.data.overview,
imageUrl = LocalImageUrlService.current.rememberImageUrl(it),
aspectRatio =
it.data.primaryImageAspectRatio?.toFloat()
?: AspectRatios.WIDE,
aspectRatio = it.aspectRatio ?: AspectRatios.WIDE,
onClick = {
viewModel.reportInteraction()
viewModel.playNextUp()
@ -545,6 +550,7 @@ fun PlaybackPage(
subtitleStreams = mediaInfo?.subtitleStreams.orEmpty(),
playbackSpeed = playbackSpeed,
contentScale = contentScale,
subtitleDelay = subtitleDelay,
),
onDismissRequest = {
playbackDialog = null
@ -555,8 +561,16 @@ fun PlaybackPage(
onControllerInteraction = {
controllerViewState.pulseControls(Long.MAX_VALUE)
},
onClickPlaybackDialogType = { playbackDialog = it },
onClickPlaybackDialogType = {
if (it == PlaybackDialogType.SUBTITLE_DELAY) {
// Hide controls so subtitles are fully visible
controllerViewState.hideControls()
}
playbackDialog = it
},
onPlaybackActionClick = onPlaybackActionClick,
onChangeSubtitleDelay = { viewModel.updateSubtitleDelay(it) },
enableSubtitleDelay = player is MpvPlayer,
)
}
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,109 @@
package com.github.damontecres.wholphin.ui.playback
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.components.Button
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
private val delayIncrements = listOf(50.milliseconds, 250.milliseconds, 1.seconds)
@Composable
fun SubtitleDelay(
delay: Duration,
onChangeDelay: (Duration) -> Unit,
modifier: Modifier = Modifier,
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier,
) {
Text(
text = stringResource(R.string.subtitle_delay) + ": " + delay.toString(),
color = MaterialTheme.colorScheme.onSurface,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
delayIncrements.reversed().forEach {
SubtitleDelayButton(
text = "-$it",
onClick = { onChangeDelay.invoke(-it) },
modifier = Modifier,
)
}
SubtitleDelayButton(
text = stringResource(R.string.reset),
onClick = { onChangeDelay.invoke(-delay) },
modifier = Modifier.focusRequester(focusRequester),
)
delayIncrements.forEach {
SubtitleDelayButton(
text = "+$it",
onClick = { onChangeDelay.invoke(it) },
modifier = Modifier,
)
}
}
}
}
@Composable
fun SubtitleDelayButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Button(
onClick = onClick,
modifier = modifier.width(64.dp),
shape =
ClickableSurfaceDefaults.shape(
shape = RoundedCornerShape(33),
),
) {
Text(
text = text,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
}
@PreviewTvSpec
@Composable
private fun SubtitleDelayPreview() {
WholphinTheme {
SubtitleDelay(
delay = 1.5.seconds,
onChangeDelay = {},
modifier = Modifier,
)
}
}

View file

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

View file

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

View file

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

View file

@ -1,8 +1,17 @@
package com.github.damontecres.wholphin.ui.setup
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
@ -13,18 +22,26 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.Icon
import androidx.tv.material3.IconButtonDefaults
import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogPopup
@ -157,3 +174,261 @@ fun ServerList(
)
}
}
/**
* Generate a consistent color for a UUID
*/
@Composable
fun rememberIdColor(
id: UUID?,
nullColor: Color = MaterialTheme.colorScheme.surfaceVariant,
): Color =
remember(id) {
if (id == null) {
return@remember nullColor
}
// Generate a color based on the server ID hash, fallback to URL hash
val hash = id.hashCode()
val hue = (hash % 360).toFloat()
val saturation = 0.6f + ((hash / 360) % 40).toFloat() / 100f // 0.6-1.0
val brightness = 0.4f + ((hash / 14400) % 30).toFloat() / 100f // 0.4-0.7 (darker colors)
// Convert HSV to RGB
val c = brightness * saturation
val x = c * (1 - kotlin.math.abs((hue / 60f) % 2f - 1))
val m = brightness - c
val (r, g, b) =
when {
hue < 60 -> Triple(c, x, 0f)
hue < 120 -> Triple(x, c, 0f)
hue < 180 -> Triple(0f, c, x)
hue < 240 -> Triple(0f, x, c)
hue < 300 -> Triple(x, 0f, c)
else -> Triple(c, 0f, x)
}
Color(
red = (r + m).coerceIn(0f, 1f),
green = (g + m).coerceIn(0f, 1f),
blue = (b + m).coerceIn(0f, 1f),
)
}
/**
* Server icon card component - displays a circular card with server name/letter
*/
@Composable
fun ServerIconCard(
server: JellyfinServer,
connectionStatus: ServerConnectionStatus,
isCurrentServer: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
allowDelete: Boolean = false,
) {
val interactionSource = remember { MutableInteractionSource() }
// Generate unique color for this server
val serverColor = rememberIdColor(server.id)
// Card dimensions - circular card
val cardSize = Cards.serverUserCircle
val displayText =
remember(server) {
(server.name ?: server.url.replace(Regex("^https?://"), ""))
.firstOrNull()
?.uppercase()
?: "?"
}
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
// Circular card with colored background
Surface(
onClick = onClick,
onLongClick = if (allowDelete) onLongClick else null,
interactionSource = interactionSource,
modifier = Modifier.size(cardSize),
shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
colors =
ClickableSurfaceDefaults.colors(
containerColor =
if (isCurrentServer) {
serverColor.copy(alpha = 0.7f)
} else {
serverColor.copy(alpha = 0.5f)
},
focusedContainerColor =
if (isCurrentServer) {
serverColor.copy(alpha = 0.9f)
} else {
serverColor.copy(alpha = 0.7f)
},
),
border =
ClickableSurfaceDefaults.border(
focusedBorder =
Border(
border =
BorderStroke(
width = 3.dp,
color = MaterialTheme.colorScheme.onSurface,
),
shape = CircleShape,
),
),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.2f),
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
// Show connection status indicator or server name/letter
when (connectionStatus) {
is ServerConnectionStatus.Success -> {
// Show server name/letter
Text(
text = displayText,
style =
MaterialTheme.typography.displayLarge.copy(
fontWeight = FontWeight.Bold,
),
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
)
}
ServerConnectionStatus.Pending -> {
CircularProgress(
modifier = Modifier.size(cardSize * 0.4f),
)
}
is ServerConnectionStatus.Error -> {
// Show warning icon with server letter below
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = connectionStatus.message,
tint = MaterialTheme.colorScheme.errorContainer,
modifier = Modifier.size(cardSize * 0.3f),
)
Text(
text = displayText,
style =
MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Bold,
),
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
)
}
}
}
}
}
// Server name below the card
Text(
text = server.name?.ifBlank { null } ?: server.url,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.width(cardSize)
.padding(horizontal = 4.dp),
)
}
}
/**
* Add Server card component - displays a + icon in a circle
*/
@Composable
fun AddServerCard(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val interactionSource = remember { MutableInteractionSource() }
// Use a neutral gray color for the add server card
val addServerColor = MaterialTheme.colorScheme.surfaceVariant
// Card dimensions - circular card (same as server cards)
val cardSize = Cards.height2x3 * 0.75f // ~120dp
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
// Circular card with colored background
Surface(
onClick = onClick,
interactionSource = interactionSource,
modifier = Modifier.size(cardSize),
shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
colors =
ClickableSurfaceDefaults.colors(
containerColor = addServerColor.copy(alpha = 0.4f),
focusedContainerColor = addServerColor.copy(alpha = 0.6f),
),
border =
ClickableSurfaceDefaults.border(
focusedBorder =
Border(
border =
BorderStroke(
width = 3.dp,
color = MaterialTheme.colorScheme.onSurface,
),
shape = CircleShape,
),
),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.2f),
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = stringResource(R.string.add_server),
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(cardSize * 0.4f), // Size of the + icon
)
}
}
// "Add Server" text below the card
Text(
text = stringResource(R.string.add_server),
style =
MaterialTheme.typography.bodyLarge.copy(
fontWeight = FontWeight.Bold,
),
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.width(cardSize)
.padding(horizontal = 4.dp),
)
}
}

View file

@ -1,16 +1,23 @@
package com.github.damontecres.wholphin.ui.setup
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -22,6 +29,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
@ -29,19 +38,21 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.EditTextBox
import com.github.damontecres.wholphin.ui.components.TextButton
import com.github.damontecres.wholphin.ui.dimAndBlur
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState
import org.jellyfin.sdk.model.api.PublicSystemInfo
@Composable
fun SwitchServerContent(
@ -51,141 +62,185 @@ fun SwitchServerContent(
val servers by viewModel.servers.observeAsState(listOf())
val serverStatus by viewModel.serverStatus.observeAsState(mapOf())
val discoveredServers by viewModel.discoveredServers.observeAsState(listOf())
var showAddServer by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf<com.github.damontecres.wholphin.data.model.JellyfinServer?>(null) }
LaunchedEffect(Unit) {
viewModel.init()
viewModel.discoverServers()
}
Box(
modifier = modifier.dimAndBlur(showAddServer),
modifier = modifier.dimAndBlur(showAddServer || showDeleteDialog != null),
) {
Row(
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
modifier =
Modifier
.fillMaxWidth()
// Center the content like the Select User screen
.align(Alignment.Center)
.padding(32.dp),
.padding(16.dp),
) {
// Match SwitchUser header height (title + subtitle) to align icons vertically across screens
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.fillMaxWidth()
.weight(1f)
.padding(16.dp)
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
shape = RoundedCornerShape(16.dp),
),
) {
Text(
text = stringResource(R.string.select_server),
style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.onSurface,
)
ServerList(
servers = servers,
connectionStatus = serverStatus,
onSwitchServer = {
viewModel.switchServer(it)
},
onTestServer = {
viewModel.testServer(it)
},
onAddServer = {
showAddServer = true
},
onRemoveServer = {
viewModel.removeServer(it)
},
allowAdd = true,
allowDelete = true,
modifier =
Modifier
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
shape = RoundedCornerShape(16.dp),
),
// Invisible subtitle placeholder to mirror the server name line on the Select User screen
Text(
text = "Server placeholder",
style = MaterialTheme.typography.titleLarge,
color = Color.Transparent,
)
}
// Discover
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
// Horizontal scrollable list of server icons - centered
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
val focusRequester = remember { FocusRequester() }
val firstServerFocus = remember { FocusRequester() }
if (servers.isNotEmpty()) {
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
}
LazyRow(
horizontalArrangement = Arrangement.spacedBy(24.dp),
contentPadding = PaddingValues(horizontal = 48.dp, vertical = 16.dp),
modifier =
Modifier
.wrapContentWidth()
.focusRestorer(firstServerFocus)
.focusRequester(focusRequester),
) {
itemsIndexed(servers) { index, server ->
val status = serverStatus[server.id] ?: ServerConnectionStatus.Pending
ServerIconCard(
server = server,
connectionStatus = status,
isCurrentServer = false, // TODO: Determine current server if needed
onClick = {
when (status) {
is ServerConnectionStatus.Success -> {
viewModel.switchServer(server)
}
ServerConnectionStatus.Pending -> {
// Do nothing while pending
}
is ServerConnectionStatus.Error -> {
viewModel.testServer(server)
}
}
},
onLongClick = {
showDeleteDialog = server
},
allowDelete = true,
modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(firstServerFocus)),
)
}
// Add Server card - always rightmost
item {
AddServerCard(
onClick = { showAddServer = true },
)
}
}
}
// Non-focusable spacer to mirror the space occupied by the "Switch Servers" button
Spacer(
modifier =
Modifier
.fillMaxWidth()
.weight(1f)
.padding(16.dp)
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
shape = RoundedCornerShape(16.dp),
),
) {
Text(
text = stringResource(R.string.discovered_servers),
style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.onSurface,
)
if (discoveredServers.isEmpty()) {
Text(
text = stringResource(R.string.searching),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
} else {
ServerList(
servers = discoveredServers,
connectionStatus =
discoveredServers
.map { it.id }
.associateWith { ServerConnectionStatus.Success(PublicSystemInfo()) },
onSwitchServer = {
viewModel.addServer(it.url)
.height(56.dp),
// approximate TV button height
)
}
// Delete server dialog
showDeleteDialog?.let { server ->
DialogPopup(
showDialog = true,
title = server.name ?: server.url,
dialogItems =
listOf(
DialogItem(
stringResource(R.string.switch_servers),
R.string.fa_arrow_left_arrow_right,
) {
viewModel.switchServer(server)
showDeleteDialog = null
},
onTestServer = {
viewModel.testServer(it)
DialogItem(
stringResource(R.string.delete),
Icons.Default.Delete,
Color.Red.copy(alpha = .8f),
) {
viewModel.removeServer(server)
showDeleteDialog = null
},
onAddServer = {},
onRemoveServer = {},
allowAdd = false,
allowDelete = false,
modifier =
Modifier
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
shape = RoundedCornerShape(16.dp),
),
)
}
}
),
onDismissRequest = { showDeleteDialog = null },
dismissOnClick = true,
waitToLoad = true,
properties = DialogProperties(),
elevation = 5.dp,
)
}
if (showAddServer) {
var showEnterAddress by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
viewModel.clearAddServerState()
if (!showEnterAddress) {
viewModel.discoverServers()
}
}
val state by viewModel.addServerState.observeAsState(LoadingState.Pending)
val url = rememberTextFieldState()
val submit = {
viewModel.addServer(url.text.toString())
val discoveredServers by viewModel.discoveredServers.observeAsState(listOf())
// Filter out duplicates within the discovered servers list (same URL appearing multiple times)
val filteredDiscoveredServers =
remember(discoveredServers) {
val seenUrls = mutableSetOf<String>()
discoveredServers.filter { server ->
val normalizedUrl = server.url.lowercase().trim()
if (normalizedUrl in seenUrls) {
false // Duplicate, filter it out
} else {
seenUrls.add(normalizedUrl)
true // First occurrence, keep it
}
}
}
val firstDiscoveredServerFocusRequester = remember { FocusRequester() }
// Default focus to first discovered server if available
LaunchedEffect(filteredDiscoveredServers.isNotEmpty(), showEnterAddress) {
if (!showEnterAddress && filteredDiscoveredServers.isNotEmpty()) {
firstDiscoveredServerFocusRequester.tryRequestFocus()
}
}
BasicDialog(
onDismissRequest = {
showAddServer = false
showEnterAddress = false
viewModel.clearAddServerState()
},
properties = DialogProperties(usePlatformDefaultWidth = false),
elevation = 10.dp,
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
@ -194,45 +249,134 @@ fun SwitchServerContent(
.padding(16.dp)
.fillMaxWidth(.4f),
) {
Text(
text = stringResource(R.string.enter_server_url),
)
EditTextBox(
state = url,
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrectEnabled = false,
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Go,
),
onKeyboardAction = { submit.invoke() },
modifier =
Modifier
.focusRequester(focusRequester)
.fillMaxWidth(),
)
when (val st = state) {
is LoadingState.Error -> {
if (!showEnterAddress) {
// Show discovered servers first
Text(
text = stringResource(R.string.discovered_servers),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
if (filteredDiscoveredServers.isEmpty() && discoveredServers.isEmpty()) {
Text(
text =
st.message ?: st.exception?.localizedMessage
?: "An error occurred",
color = MaterialTheme.colorScheme.error,
text = stringResource(R.string.searching),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
} else if (filteredDiscoveredServers.isEmpty()) {
Text(
text = stringResource(R.string.no_servers_found),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
} else {
LazyColumn(
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 300.dp),
) {
items(
filteredDiscoveredServers.size,
key = { filteredDiscoveredServers[it].url },
) { index ->
val server = filteredDiscoveredServers[index]
val focusRequester =
if (index == 0) {
firstDiscoveredServerFocusRequester
} else {
remember { FocusRequester() }
}
ListItem(
enabled = true,
selected = false,
headlineContent = {
Text(
text = server.name?.ifBlank { null } ?: server.url,
style = MaterialTheme.typography.bodyLarge,
)
},
supportingContent = {
Text(
text = server.url,
style = MaterialTheme.typography.bodyMedium,
)
},
onClick = {
viewModel.addServer(server.url)
},
modifier = Modifier.focusRequester(focusRequester),
)
}
}
}
else -> {}
}
TextButton(
onClick = { submit.invoke() },
enabled = url.text.isNotNullOrBlank() && state == LoadingState.Pending,
modifier = Modifier,
) {
if (state == LoadingState.Loading) {
CircularProgress(Modifier.size(32.dp))
} else {
Text(text = stringResource(R.string.submit))
TextButton(
onClick = {
showEnterAddress = true
},
modifier = Modifier.align(Alignment.CenterHorizontally),
) {
Text(text = stringResource(R.string.enter_server_address))
}
} else {
// Show enter server address form
val state by viewModel.addServerState.observeAsState(LoadingState.Pending)
var url by remember { mutableStateOf("") }
val submit = {
viewModel.addServer(url)
}
val textBoxFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
textBoxFocusRequester.tryRequestFocus()
}
Text(
text = stringResource(R.string.enter_server_url),
)
EditTextBox(
value = url,
onValueChange = { url = it },
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrectEnabled = false,
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Go,
),
keyboardActions =
KeyboardActions(
onGo = { submit.invoke() },
),
modifier =
Modifier
.focusRequester(textBoxFocusRequester)
.fillMaxWidth(),
)
when (val st = state) {
is LoadingState.Error -> {
Text(
text =
st.message ?: st.exception?.localizedMessage
?: "An error occurred",
color = MaterialTheme.colorScheme.error,
)
}
else -> {}
}
TextButton(
onClick = { submit.invoke() },
enabled = url.isNotNullOrBlank() && state == LoadingState.Pending,
modifier = Modifier,
) {
if (state == LoadingState.Loading) {
CircularProgress(Modifier.size(32.dp))
} else {
Text(text = stringResource(R.string.submit))
}
}
}
}

View file

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

View file

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

View file

@ -1,37 +1,68 @@
package com.github.damontecres.wholphin.ui.setup
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.tv.material3.Border
import androidx.tv.material3.Button
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.Icon
import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.tryRequestFocus
/**
* Display a list of users plus option to add a new one or switch servers
* Redesigned to match streaming service style with horizontal scrollable user icons
*/
@Composable
fun UserList(
users: List<JellyfinUser>,
users: List<JellyfinUserAndImage>,
currentUser: JellyfinUser?,
onSwitchUser: (JellyfinUser) -> Unit,
onAddUser: () -> Unit,
@ -41,59 +72,76 @@ fun UserList(
) {
var showDeleteDialog by remember { mutableStateOf<JellyfinUser?>(null) }
LazyColumn(modifier = modifier) {
items(users) { user ->
ListItem(
enabled = true,
selected = user == currentUser,
headlineContent = { Text(text = user.name ?: user.id.toString()) },
leadingContent = {
if (user.id == currentUser?.id) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "current user",
)
}
},
onClick = { onSwitchUser.invoke(user) },
onLongClick = {
showDeleteDialog = user
},
modifier = Modifier,
)
}
item {
HorizontalDivider()
ListItem(
enabled = true,
selected = false,
headlineContent = { Text(text = stringResource(R.string.add_user)) },
leadingContent = {
Icon(
imageVector = Icons.Default.Add,
tint = Color.Green.copy(alpha = .8f),
contentDescription = null,
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
// Horizontal scrollable list of user icons - centered
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
val focusRequester = remember { FocusRequester() }
val firstFocusRequester = remember { FocusRequester() }
if (users.isNotEmpty()) {
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
}
LazyRow(
horizontalArrangement = Arrangement.spacedBy(24.dp), // Spacing to accommodate 20% scale
contentPadding = PaddingValues(horizontal = 48.dp, vertical = 16.dp), // Increased padding to accommodate 20% scale
modifier =
Modifier
.wrapContentWidth()
.focusRestorer(firstFocusRequester)
.focusRequester(focusRequester),
) {
itemsIndexed(users) { index, user ->
UserIconCard(
user = user,
isCurrentUser = user.user.id == currentUser?.id,
onClick = { onSwitchUser.invoke(user.user) },
onLongClick = { showDeleteDialog = user.user },
modifier = if (index == 0) Modifier.focusRequester(firstFocusRequester) else Modifier,
)
},
onClick = { onAddUser.invoke() },
modifier = Modifier,
)
}
// Add User card - always rightmost
item {
AddUserCard(
onClick = { onAddUser.invoke() },
)
}
}
}
item {
HorizontalDivider()
ListItem(
enabled = true,
selected = false,
headlineContent = { Text(text = stringResource(R.string.switch_servers)) },
leadingContent = {
// Switch servers button below user list - centered
Row(
horizontalArrangement = Arrangement.Center,
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
) {
Button(
onClick = { onSwitchServer.invoke() },
modifier = Modifier.width(200.dp), // Fixed width for consistency
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.fa_arrow_left_arrow_right),
fontFamily = FontAwesome,
modifier = Modifier.padding(end = 8.dp),
)
},
onClick = { onSwitchServer.invoke() },
modifier = Modifier,
)
Text(
text = stringResource(R.string.switch_servers),
textAlign = TextAlign.Center,
)
}
}
}
}
showDeleteDialog?.let { user ->
@ -124,3 +172,193 @@ fun UserList(
)
}
}
@Composable
private fun UserIconCard(
user: JellyfinUserAndImage,
isCurrentUser: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val interactionSource = remember { MutableInteractionSource() }
// Generate unique color for this user
val userColor = rememberIdColor(user.user.id)
// Track image loading errors
var imageError by remember { mutableStateOf(false) }
// Card dimensions - circular card
val cardSize = Cards.serverUserCircle
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
// Circular card with colored background
Surface(
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
modifier = Modifier.size(cardSize),
shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
colors =
ClickableSurfaceDefaults.colors(
containerColor =
if (isCurrentUser) {
userColor.copy(alpha = 0.7f)
} else {
userColor.copy(alpha = 0.5f)
},
focusedContainerColor =
if (isCurrentUser) {
userColor.copy(alpha = 0.9f)
} else {
userColor.copy(alpha = 0.7f)
},
),
border =
ClickableSurfaceDefaults.border(
focusedBorder =
Border(
border =
BorderStroke(
width = 3.dp,
color = MaterialTheme.colorScheme.onSurface,
),
shape = CircleShape,
),
),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.2f),
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
if (user.imageUrl.isNotNullOrBlank() && !imageError) {
AsyncImage(
model = user.imageUrl,
contentDescription = user.user.name,
contentScale = ContentScale.Crop,
onError = { imageError = true },
modifier =
Modifier
.fillMaxSize()
.clip(CircleShape),
)
} else {
// Show big bold first letter of username
val firstLetter =
remember(user) {
user.user.let {
(it.name ?: it.id.toString()).firstOrNull()?.uppercase()
} ?: "?"
}
Text(
text = firstLetter,
style =
MaterialTheme.typography.displayLarge.copy(
fontWeight = FontWeight.Bold,
),
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
)
}
}
}
// Username below the card
Text(
text = user.user.name ?: user.user.id.toString(),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.width(cardSize)
.padding(horizontal = 4.dp),
)
}
}
/**
* Add User card component - displays a + icon in a circle
*/
@Composable
private fun AddUserCard(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val interactionSource = remember { MutableInteractionSource() }
// Use a neutral gray color for the add user card
val addUserColor = MaterialTheme.colorScheme.surfaceVariant
// Card dimensions - circular card (same as user cards)
val cardSize = Cards.height2x3 * 0.75f // ~120dp
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp), // Increased to accommodate 20% scale
) {
// Circular card with colored background
Surface(
onClick = onClick,
interactionSource = interactionSource,
modifier = Modifier.size(cardSize),
shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
colors =
ClickableSurfaceDefaults.colors(
containerColor = addUserColor.copy(alpha = 0.4f),
focusedContainerColor = addUserColor.copy(alpha = 0.6f),
),
border =
ClickableSurfaceDefaults.border(
focusedBorder =
Border(
border =
BorderStroke(
width = 3.dp,
color = MaterialTheme.colorScheme.onSurface,
),
shape = CircleShape,
),
),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.2f),
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = stringResource(R.string.add_user),
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(cardSize * 0.4f), // Size of the + icon
)
}
}
// "Add User" text below the card
Text(
text = stringResource(R.string.add_user),
style =
MaterialTheme.typography.bodyLarge.copy(
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
),
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.width(cardSize)
.padding(horizontal = 4.dp),
)
}
}

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