mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Merge branch 'main' into fea/libass-android-support
This commit is contained in:
commit
6f17abce3a
179 changed files with 22968 additions and 2499 deletions
37
.github/actions/native-build/action.yml
vendored
37
.github/actions/native-build/action.yml
vendored
|
|
@ -15,6 +15,25 @@ runs:
|
|||
path: |
|
||||
app/libs
|
||||
key: ${{ runner.os }}-ffmpeg-${{ hashFiles('**/libs.versions.toml', '**/scripts/ffmpeg/build_ffmpeg_decoder.sh') }}
|
||||
- name: Load libmpv module cache
|
||||
id: cache_libmpv_module
|
||||
if: ${{ inputs.cache }}
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: |
|
||||
app/src/main/libs
|
||||
key: ${{ runner.os }}-libmpv-${{ hashFiles('**/scripts/mpv/*.sh', '**/scripts/mpv/include/*', '**/scripts/mpv/scripts/*', 'app/src/main/jni/*') }}
|
||||
- name: Install dependencies
|
||||
if: steps.cache_ffmpeg_module.outputs.cache-hit != 'true' || steps.cache_libmpv_module.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install jsonschema jinja2
|
||||
sudo apt update
|
||||
sudo apt install -y build-essential autoconf pkg-config libtool ninja-build unzip wget meson nasm
|
||||
|
||||
|
||||
# ffmpeg
|
||||
- name: Build ffmpeg decoder
|
||||
id: ffmpeg-decoder
|
||||
if: steps.cache_ffmpeg_module.outputs.cache-hit != 'true'
|
||||
|
|
@ -30,23 +49,7 @@ runs:
|
|||
app/libs
|
||||
key: ${{ steps.cache_ffmpeg_module.outputs.cache-primary-key }}
|
||||
|
||||
- name: Load libmpv module cache
|
||||
id: cache_libmpv_module
|
||||
if: ${{ inputs.cache }}
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: |
|
||||
app/src/main/libs
|
||||
key: ${{ runner.os }}-libmpv-${{ hashFiles('**/scripts/mpv/*.sh', '**/scripts/mpv/include/*', '**/scripts/mpv/scripts/*', 'app/src/main/jni/*') }}
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.cache_libmpv_module.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install jsonschema jinja2
|
||||
sudo apt update
|
||||
sudo apt install -y build-essential autoconf pkg-config libtool ninja-build unzip wget meson
|
||||
# libmpv
|
||||
- name: Get libmpv dependencies
|
||||
if: steps.cache_libmpv_module.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
|
|
|
|||
2
.github/workflows/pr.yml
vendored
2
.github/workflows/pr.yml
vendored
|
|
@ -43,7 +43,7 @@ jobs:
|
|||
- name: Build app
|
||||
id: buildapp
|
||||
run: |
|
||||
./gradlew clean assembleDebug --no-daemon
|
||||
./gradlew clean assembleDebug testDebugUnitTest --no-daemon
|
||||
apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//')
|
||||
echo "apks=$apks" >> "$GITHUB_OUTPUT"
|
||||
- name: Tar build dirs
|
||||
|
|
|
|||
|
|
@ -15,11 +15,13 @@ plugins {
|
|||
alias(libs.plugins.protobuf)
|
||||
alias(libs.plugins.kotlin.plugin.serialization)
|
||||
alias(libs.plugins.aboutLibraries)
|
||||
alias(libs.plugins.openapi.generator)
|
||||
}
|
||||
|
||||
val isCI = if (System.getenv("CI") != null) System.getenv("CI").toBoolean() else false
|
||||
val shouldSign = isCI && System.getenv("KEY_ALIAS") != null
|
||||
val ffmpegModuleExists = project.file("libs/lib-decoder-ffmpeg-release.aar").exists()
|
||||
val av1ModuleExists = project.file("libs/lib-decoder-av1-release.aar").exists()
|
||||
|
||||
val gitTags =
|
||||
providers
|
||||
|
|
@ -145,6 +147,12 @@ android {
|
|||
isUniversalApk = true
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
kotlin.srcDirs("$buildDir/generated/seerr_api/src/main/kotlin")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protobuf {
|
||||
|
|
@ -176,6 +184,33 @@ aboutLibraries {
|
|||
}
|
||||
}
|
||||
|
||||
openApiGenerate {
|
||||
generatorName.set("kotlin")
|
||||
inputSpec.set("$projectDir/src/main/seerr/seerr-api.yml")
|
||||
templateDir.set("$projectDir/src/main/seerr/templates")
|
||||
outputDir.set("$buildDir/generated/seerr_api")
|
||||
apiPackage.set("com.github.damontecres.wholphin.api.seerr")
|
||||
modelPackage.set("com.github.damontecres.wholphin.api.seerr.model")
|
||||
groupId.set("com.github.damontecres.wholphin.api.seerr")
|
||||
id.set("seerr-api")
|
||||
packageName.set("com.github.damontecres.wholphin.api.seerr")
|
||||
additionalProperties.apply {
|
||||
put("serializationLibrary", "kotlinx_serialization")
|
||||
put("sortModelPropertiesByRequiredFlag", true)
|
||||
put("sortParamsByRequiredFlag", true)
|
||||
put("useCoroutines", true)
|
||||
put("enumPropertyNaming", "UPPERCASE")
|
||||
put("modelMutable", false)
|
||||
|
||||
// Note: this is only for downloading files, so it's not necessary to enable
|
||||
put("supportAndroidApiLevel25AndBelow", false)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named("preBuild") {
|
||||
dependsOn.add(tasks.named("openApiGenerate"))
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.appcompat)
|
||||
|
|
@ -191,10 +226,15 @@ dependencies {
|
|||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.datastore)
|
||||
implementation(libs.protobuf.kotlin.lite)
|
||||
implementation(libs.androidx.tvprovider)
|
||||
implementation(libs.androidx.work.runtime.ktx)
|
||||
implementation(libs.androidx.hilt.work)
|
||||
|
||||
implementation(libs.androidx.media3.exoplayer)
|
||||
implementation(libs.androidx.media3.session)
|
||||
implementation(libs.androidx.media3.datasource.okhttp)
|
||||
implementation(libs.androidx.media3.exoplayer.hls)
|
||||
implementation(libs.androidx.media3.exoplayer.dash)
|
||||
implementation(libs.androidx.media3.ui)
|
||||
implementation(libs.androidx.media3.ui.compose)
|
||||
implementation(libs.ass.media)
|
||||
|
|
@ -228,6 +268,7 @@ dependencies {
|
|||
implementation(libs.androidx.palette.ktx)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
ksp(libs.hilt.android.compiler)
|
||||
ksp(libs.androidx.hilt.compiler)
|
||||
|
||||
implementation(libs.timber)
|
||||
implementation(libs.slf4j2.timber)
|
||||
|
|
@ -241,6 +282,8 @@ dependencies {
|
|||
implementation(libs.acra.limiter)
|
||||
compileOnly(libs.auto.service.annotations)
|
||||
ksp(libs.auto.service.ksp)
|
||||
implementation(platform(libs.okhttp.bom))
|
||||
implementation(libs.okhttp)
|
||||
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
|
|
@ -250,4 +293,11 @@ dependencies {
|
|||
if (ffmpegModuleExists || isCI) {
|
||||
implementation(files("libs/lib-decoder-ffmpeg-release.aar"))
|
||||
}
|
||||
if (av1ModuleExists || isCI) {
|
||||
implementation(files("libs/lib-decoder-av1-release.aar"))
|
||||
}
|
||||
|
||||
testImplementation(libs.mockk.android)
|
||||
testImplementation(libs.mockk.agent)
|
||||
testImplementation(libs.robolectric)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,549 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 20,
|
||||
"identityHash": "dea51adcc724179afa0174d775f97480",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "seerr_servers",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "version",
|
||||
"columnName": "version",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_seerr_servers_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"url"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "seerr_users",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "jellyfinUserRowId",
|
||||
"columnName": "jellyfinUserRowId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serverId",
|
||||
"columnName": "serverId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "authMethod",
|
||||
"columnName": "authMethod",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "username",
|
||||
"columnName": "username",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "password",
|
||||
"columnName": "password",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "credential",
|
||||
"columnName": "credential",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"jellyfinUserRowId",
|
||||
"serverId"
|
||||
]
|
||||
},
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "seerr_servers",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"serverId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "users",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"jellyfinUserRowId"
|
||||
],
|
||||
"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, 'dea51adcc724179afa0174d775f97480')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
|
@ -11,6 +12,7 @@
|
|||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
|
|
@ -22,6 +24,13 @@
|
|||
android:name="android.hardware.microphone"
|
||||
android:required="false" />
|
||||
|
||||
<!-- Required for Android 11+ to query voice recognition availability -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.speech.action.RECOGNIZE_SPEECH" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:banner="@mipmap/ic_banner"
|
||||
|
|
@ -30,12 +39,13 @@
|
|||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Wholphin"
|
||||
android:name=".WholphinApplication"
|
||||
android:usesCleartextTraffic="true">
|
||||
android:usesCleartextTraffic="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout">
|
||||
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
|
|
@ -53,6 +63,10 @@
|
|||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/provider_paths" />
|
||||
</provider>
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
tools:node="remove" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package com.github.damontecres.wholphin
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
|
|
@ -13,13 +15,17 @@ import androidx.compose.runtime.CompositionLocalProvider
|
|||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.RectangleShape
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||
|
|
@ -35,19 +41,25 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
|
|||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.AppUpgradeHandler
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.DatePlayedInvalidationService
|
||||
import com.github.damontecres.wholphin.services.DeviceProfileService
|
||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver
|
||||
import com.github.damontecres.wholphin.services.RefreshRateService
|
||||
import com.github.damontecres.wholphin.services.ServerEventListener
|
||||
import com.github.damontecres.wholphin.services.SetupDestination
|
||||
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
||||
import com.github.damontecres.wholphin.services.UpdateChecker
|
||||
import com.github.damontecres.wholphin.services.UserSwitchListener
|
||||
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
||||
import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService
|
||||
import com.github.damontecres.wholphin.ui.CoilConfig
|
||||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
||||
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.setup.SwitchServerContent
|
||||
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
|
||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
|
|
@ -55,11 +67,9 @@ import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
|
|||
import com.github.damontecres.wholphin.util.DebugLogTree
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
|
@ -96,6 +106,20 @@ class MainActivity : AppCompatActivity() {
|
|||
@Inject
|
||||
lateinit var refreshRateService: RefreshRateService
|
||||
|
||||
@Inject
|
||||
lateinit var userSwitchListener: UserSwitchListener
|
||||
|
||||
@Inject
|
||||
lateinit var tvProviderSchedulerService: TvProviderSchedulerService
|
||||
|
||||
// Note: unused but injected to ensure it is created
|
||||
@Inject
|
||||
lateinit var serverEventListener: ServerEventListener
|
||||
|
||||
// Note: unused but injected to ensure it is created
|
||||
@Inject
|
||||
lateinit var datePlayedInvalidationService: DatePlayedInvalidationService
|
||||
|
||||
private var signInAuto = true
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
|
|
@ -106,15 +130,16 @@ class MainActivity : AppCompatActivity() {
|
|||
if (savedInstanceState == null) {
|
||||
appUpgradeHandler.copySubfont(false)
|
||||
}
|
||||
refreshRateService.refreshRateMode.observe(this) { mode ->
|
||||
refreshRateService.refreshRateMode.observe(this) { modeId ->
|
||||
// Listen for refresh rate changes
|
||||
val attrs = window.attributes
|
||||
if (attrs.preferredDisplayModeId != mode.modeId) {
|
||||
Timber.d("Switch preferredRefreshRate to %s", mode.refreshRate)
|
||||
window.attributes = attrs.apply { preferredRefreshRate = mode.refreshRate }
|
||||
if (attrs.preferredDisplayModeId != modeId) {
|
||||
Timber.d("Switch preferredDisplayModeId to %s", modeId)
|
||||
window.attributes = attrs.apply { preferredDisplayModeId = modeId }
|
||||
}
|
||||
}
|
||||
viewModel.appStart()
|
||||
val requestedDestination = this.intent?.let(::extractDestination)
|
||||
setContent {
|
||||
val appPreferences by userPreferencesDataStore.data.collectAsState(null)
|
||||
appPreferences?.let { appPreferences ->
|
||||
|
|
@ -211,13 +236,37 @@ class MainActivity : AppCompatActivity() {
|
|||
remember(appPreferences) {
|
||||
UserPreferences(appPreferences)
|
||||
}
|
||||
ApplicationContent(
|
||||
user = current.user,
|
||||
server = current.server,
|
||||
navigationManager = navigationManager,
|
||||
preferences = preferences,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
var showContent by remember {
|
||||
mutableStateOf(true)
|
||||
}
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
|
||||
if (!preferences.appPreferences.signInAutomatically) {
|
||||
showContent = false
|
||||
}
|
||||
}
|
||||
|
||||
if (showContent) {
|
||||
ApplicationContent(
|
||||
user = current.user,
|
||||
server = current.server,
|
||||
startDestination =
|
||||
requestedDestination
|
||||
?: Destination.Home(),
|
||||
navigationManager = navigationManager,
|
||||
preferences = preferences,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier.size(200.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
color = MaterialTheme.colorScheme.border,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -233,7 +282,7 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
Timber.i("onResume")
|
||||
Timber.d("onResume")
|
||||
lifecycleScope.launchIO {
|
||||
appUpgradeHandler.run()
|
||||
}
|
||||
|
|
@ -241,7 +290,7 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
override fun onRestart() {
|
||||
super.onRestart()
|
||||
Timber.i("onRestart")
|
||||
Timber.d("onRestart")
|
||||
viewModel.appStart()
|
||||
// val signInAutomatically =
|
||||
// runBlocking { userPreferencesDataStore.data.firstOrNull()?.signInAutomatically } ?: true
|
||||
|
|
@ -253,35 +302,89 @@ class MainActivity : AppCompatActivity() {
|
|||
// }
|
||||
}
|
||||
|
||||
// override fun onStop() {
|
||||
// super.onStop()
|
||||
// Timber.i("onStop")
|
||||
// }
|
||||
//
|
||||
// override fun onPause() {
|
||||
// super.onPause()
|
||||
// Timber.i("onPause")
|
||||
// }
|
||||
//
|
||||
// override fun onStart() {
|
||||
// super.onStart()
|
||||
// Timber.i("onStart")
|
||||
// }
|
||||
//
|
||||
// override fun onSaveInstanceState(outState: Bundle) {
|
||||
// super.onSaveInstanceState(outState)
|
||||
// Timber.i("onSaveInstanceState")
|
||||
// }
|
||||
//
|
||||
// override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||
// super.onRestoreInstanceState(savedInstanceState)
|
||||
// Timber.i("onRestoreInstanceState")
|
||||
// }
|
||||
//
|
||||
// override fun onDestroy() {
|
||||
// super.onDestroy()
|
||||
// Timber.i("onDestroy")
|
||||
// }
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
Timber.d("onStop")
|
||||
tvProviderSchedulerService.launchOneTimeRefresh()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
Timber.d("onPause")
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
Timber.d("onStart")
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
Timber.d("onSaveInstanceState")
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||
super.onRestoreInstanceState(savedInstanceState)
|
||||
Timber.d("onRestoreInstanceState")
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
Timber.d("onDestroy")
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
Timber.d("onConfigurationChanged")
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
Timber.v("onNewIntent")
|
||||
extractDestination(intent)?.let {
|
||||
navigationManager.replace(it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractDestination(intent: Intent): Destination? =
|
||||
intent.let {
|
||||
val itemId =
|
||||
it.getStringExtra(INTENT_ITEM_ID)?.toUUIDOrNull()
|
||||
val type =
|
||||
it.getStringExtra(INTENT_ITEM_TYPE)?.let(BaseItemKind::fromNameOrNull)
|
||||
if (itemId != null && type != null) {
|
||||
val seriesId = it.getStringExtra(INTENT_SERIES_ID)?.toUUIDOrNull()
|
||||
val seasonId = it.getStringExtra(INTENT_SEASON_ID)?.toUUIDOrNull()
|
||||
val episodeNumber = it.getIntExtra(INTENT_EPISODE_NUMBER, -1)
|
||||
val seasonNumber = it.getIntExtra(INTENT_SEASON_NUMBER, -1)
|
||||
if (seriesId != null && seasonId != null && episodeNumber >= 0 && seasonNumber >= 0) {
|
||||
Destination.SeriesOverview(
|
||||
itemId = seriesId,
|
||||
type = BaseItemKind.SERIES,
|
||||
seasonEpisode =
|
||||
SeasonEpisodeIds(
|
||||
seasonId = seasonId,
|
||||
seasonNumber = seasonNumber,
|
||||
episodeId = itemId,
|
||||
episodeNumber = episodeNumber,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
Destination.MediaItem(itemId, type)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val INTENT_ITEM_ID = "itemId"
|
||||
const val INTENT_ITEM_TYPE = "itemType"
|
||||
const val INTENT_SERIES_ID = "seriesId"
|
||||
const val INTENT_EPISODE_NUMBER = "epNum"
|
||||
const val INTENT_SEASON_NUMBER = "seaNum"
|
||||
const val INTENT_SEASON_ID = "seaId"
|
||||
}
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
|
|
@ -295,40 +398,42 @@ class MainActivityViewModel
|
|||
private val backdropService: BackdropService,
|
||||
) : ViewModel() {
|
||||
fun appStart() {
|
||||
viewModelScope.launch {
|
||||
val prefs = preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
|
||||
if (prefs.signInAutomatically) {
|
||||
val current =
|
||||
withContext(Dispatchers.IO) {
|
||||
viewModelScope.launchIO {
|
||||
try {
|
||||
val prefs =
|
||||
preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
|
||||
if (prefs.signInAutomatically) {
|
||||
val current =
|
||||
serverRepository.restoreSession(
|
||||
prefs.currentServerId?.toUUIDOrNull(),
|
||||
prefs.currentUserId?.toUUIDOrNull(),
|
||||
)
|
||||
}
|
||||
if (current != null) {
|
||||
// Restored
|
||||
navigationManager.navigateTo(SetupDestination.AppContent(current))
|
||||
} else {
|
||||
// Did not restore
|
||||
navigationManager.navigateTo(SetupDestination.ServerList)
|
||||
}
|
||||
} else {
|
||||
navigationManager.navigateTo(SetupDestination.Loading)
|
||||
backdropService.clearBackdrop()
|
||||
val currentServerId = prefs.currentServerId?.toUUIDOrNull()
|
||||
if (currentServerId != null) {
|
||||
val currentServer =
|
||||
withContext(Dispatchers.IO) {
|
||||
serverRepository.serverDao.getServer(currentServerId)?.server
|
||||
}
|
||||
if (currentServer != null) {
|
||||
navigationManager.navigateTo(SetupDestination.UserList(currentServer))
|
||||
if (current != null) {
|
||||
// Restored
|
||||
navigationManager.navigateTo(SetupDestination.AppContent(current))
|
||||
} else {
|
||||
// Did not restore
|
||||
navigationManager.navigateTo(SetupDestination.ServerList)
|
||||
}
|
||||
} else {
|
||||
navigationManager.navigateTo(SetupDestination.ServerList)
|
||||
navigationManager.navigateTo(SetupDestination.Loading)
|
||||
backdropService.clearBackdrop()
|
||||
val currentServerId = prefs.currentServerId?.toUUIDOrNull()
|
||||
if (currentServerId != null) {
|
||||
val currentServer =
|
||||
serverRepository.serverDao.getServer(currentServerId)?.server
|
||||
if (currentServer != null) {
|
||||
navigationManager.navigateTo(SetupDestination.UserList(currentServer))
|
||||
} else {
|
||||
navigationManager.navigateTo(SetupDestination.ServerList)
|
||||
}
|
||||
} else {
|
||||
navigationManager.navigateTo(SetupDestination.ServerList)
|
||||
}
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error during appStart")
|
||||
navigationManager.navigateTo(SetupDestination.ServerList)
|
||||
}
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import android.os.StrictMode.ThreadPolicy
|
|||
import android.util.Log
|
||||
import androidx.compose.runtime.Composer
|
||||
import androidx.compose.runtime.ExperimentalComposeRuntimeApi
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.Configuration
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import org.acra.ACRA
|
||||
import org.acra.ReportField
|
||||
|
|
@ -14,10 +16,13 @@ import org.acra.config.dialog
|
|||
import org.acra.data.StringFormat
|
||||
import org.acra.ktx.initAcra
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@OptIn(ExperimentalComposeRuntimeApi::class)
|
||||
@HiltAndroidApp
|
||||
class WholphinApplication : Application() {
|
||||
class WholphinApplication :
|
||||
Application(),
|
||||
Configuration.Provider {
|
||||
init {
|
||||
instance = this
|
||||
|
||||
|
|
@ -94,6 +99,16 @@ class WholphinApplication : Application() {
|
|||
ACRA.errorReporter.putCustomData("SDK_INT", Build.VERSION.SDK_INT.toString())
|
||||
}
|
||||
|
||||
@Inject
|
||||
lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
override val workManagerConfiguration: Configuration
|
||||
get() =
|
||||
Configuration
|
||||
.Builder()
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
|
||||
companion object {
|
||||
lateinit var instance: WholphinApplication
|
||||
private set
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
package com.github.damontecres.wholphin.api.seerr
|
||||
|
||||
import com.github.damontecres.wholphin.api.seerr.infrastructure.ApiClient
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import okhttp3.Call
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import timber.log.Timber
|
||||
|
||||
class SeerrApiClient(
|
||||
val baseUrl: String,
|
||||
private val apiKey: String?,
|
||||
okHttpClient: OkHttpClient,
|
||||
) {
|
||||
private val cookieJar = SeerrCookieJar()
|
||||
|
||||
private val client =
|
||||
okHttpClient
|
||||
.newBuilder()
|
||||
.cookieJar(cookieJar)
|
||||
.addInterceptor {
|
||||
Timber.d("SeerrApiClient: ${it.request().method} ${it.request().url}")
|
||||
it.proceed(
|
||||
it
|
||||
.request()
|
||||
.newBuilder()
|
||||
.apply {
|
||||
if (apiKey.isNotNullOrBlank()) header("X-Api-Key", apiKey)
|
||||
}.build(),
|
||||
)
|
||||
}.build()
|
||||
|
||||
val hasValidCredentials: Boolean
|
||||
get() =
|
||||
apiKey.isNotNullOrBlank() ||
|
||||
cookieJar.hasValidCredentials(baseUrl)
|
||||
|
||||
private fun <T : ApiClient> create(initializer: (String, Call.Factory) -> T): Lazy<T> =
|
||||
lazy {
|
||||
initializer.invoke(baseUrl, client)
|
||||
}
|
||||
|
||||
val authApi by create(::AuthApi)
|
||||
val blacklistApi by create(::BlacklistApi)
|
||||
val collectionApi by create(::CollectionApi)
|
||||
val issueApi by create(::IssueApi)
|
||||
val mediaApi by create(::MediaApi)
|
||||
val moviesApi by create(::MoviesApi)
|
||||
val otherApi by create(::OtherApi)
|
||||
val overrideruleApi by create(::OverrideruleApi)
|
||||
val personApi by create(::PersonApi)
|
||||
val publicApi by create(::PublicApi)
|
||||
val requestApi by create(::RequestApi)
|
||||
val searchApi by create(::SearchApi)
|
||||
val serviceApi by create(::ServiceApi)
|
||||
val settingsApi by create(::SettingsApi)
|
||||
val tmdbApi by create(::TmdbApi)
|
||||
val tvApi by create(::TvApi)
|
||||
val usersApi by create(::UsersApi)
|
||||
val watchlistApi by create(::WatchlistApi)
|
||||
}
|
||||
|
||||
private class SeerrCookieJar : CookieJar {
|
||||
private val cookies = mutableMapOf<String, List<Cookie>>()
|
||||
|
||||
override fun saveFromResponse(
|
||||
url: HttpUrl,
|
||||
cookies: List<Cookie>,
|
||||
) {
|
||||
cookies
|
||||
.filter { it.name == "connect.sid" }
|
||||
.groupBy { it.domain }
|
||||
.forEach { (domain, cookies) ->
|
||||
this.cookies[domain] = cookies
|
||||
}
|
||||
}
|
||||
|
||||
override fun loadForRequest(url: HttpUrl): List<Cookie> = this.cookies[url.host].orEmpty()
|
||||
|
||||
fun hasValidCredentials(baseUrl: String): Boolean =
|
||||
baseUrl.toHttpUrlOrNull()?.host?.let { domain ->
|
||||
cookies[domain]?.any { it.expiresAt > System.currentTimeMillis() }
|
||||
} == true
|
||||
}
|
||||
|
|
@ -15,6 +15,8 @@ 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.data.model.SeerrServer
|
||||
import com.github.damontecres.wholphin.data.model.SeerrUser
|
||||
import com.github.damontecres.wholphin.ui.components.ViewOptions
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
|
|
@ -32,8 +34,10 @@ import java.util.UUID
|
|||
LibraryDisplayInfo::class,
|
||||
PlaybackLanguageChoice::class,
|
||||
ItemTrackModification::class,
|
||||
SeerrServer::class,
|
||||
SeerrUser::class,
|
||||
],
|
||||
version = 12,
|
||||
version = 20,
|
||||
exportSchema = true,
|
||||
autoMigrations = [
|
||||
AutoMigration(3, 4),
|
||||
|
|
@ -45,6 +49,7 @@ import java.util.UUID
|
|||
AutoMigration(9, 10),
|
||||
AutoMigration(10, 11),
|
||||
AutoMigration(11, 12),
|
||||
AutoMigration(12, 20),
|
||||
],
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
|
|
@ -58,6 +63,8 @@ abstract class AppDatabase : RoomDatabase() {
|
|||
abstract fun libraryDisplayInfoDao(): LibraryDisplayInfoDao
|
||||
|
||||
abstract fun playbackLanguageChoiceDao(): PlaybackLanguageChoiceDao
|
||||
|
||||
abstract fun seerrServerDao(): SeerrServerDao
|
||||
}
|
||||
|
||||
class Converters {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.github.damontecres.wholphin.data
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
|
|
@ -11,22 +12,25 @@ import java.util.UUID
|
|||
|
||||
@Dao
|
||||
interface ItemPlaybackDao {
|
||||
fun getItem(
|
||||
suspend fun getItem(
|
||||
user: JellyfinUser,
|
||||
itemId: UUID,
|
||||
): ItemPlayback? = getItem(user.rowId, itemId)
|
||||
|
||||
@Query("SELECT * from ItemPlayback WHERE userId=:userId AND itemId=:itemId")
|
||||
fun getItem(
|
||||
suspend fun getItem(
|
||||
userId: Int,
|
||||
itemId: UUID,
|
||||
): ItemPlayback?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun saveItem(item: ItemPlayback): Long
|
||||
suspend fun saveItem(item: ItemPlayback): Long
|
||||
|
||||
@Delete
|
||||
suspend fun deleteItem(item: ItemPlayback)
|
||||
|
||||
@Query("SELECT * from ItemPlayback WHERE userId=:userId")
|
||||
fun getItems(userId: Int): List<ItemPlayback>
|
||||
suspend fun getItems(userId: Int): List<ItemPlayback>
|
||||
|
||||
@Query("SELECT * FROM ItemTrackModification WHERE userId=:userId AND itemId=:itemId")
|
||||
suspend fun getTrackModifications(
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ class ItemPlaybackRepository
|
|||
constructor(
|
||||
val serverRepository: ServerRepository,
|
||||
val itemPlaybackDao: ItemPlaybackDao,
|
||||
private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
|
||||
private val streamChoiceService: StreamChoiceService,
|
||||
) {
|
||||
suspend fun getSelectedTracks(
|
||||
|
|
@ -61,7 +62,7 @@ class ItemPlaybackRepository
|
|||
)
|
||||
val subtitleStream =
|
||||
streamChoiceService.chooseSubtitleStream(
|
||||
audioStream = audioStream,
|
||||
audioStreamLang = audioStream?.language,
|
||||
candidates =
|
||||
source.mediaStreams
|
||||
?.filter { it.type == MediaStreamType.SUBTITLE }
|
||||
|
|
@ -132,7 +133,7 @@ class ItemPlaybackRepository
|
|||
Timber.v("Saving track selection %s", toSave)
|
||||
toSave = saveItemPlayback(toSave)
|
||||
val seriesId = item.data.seriesId
|
||||
if (seriesId != null && trackIndex != TrackIndex.UNSPECIFIED) {
|
||||
if (seriesId != null && (trackIndex >= 0 || trackIndex == TrackIndex.DISABLED)) {
|
||||
if (type == MediaStreamType.AUDIO) {
|
||||
val stream = source.mediaStreams?.first { it.index == trackIndex }
|
||||
if (stream?.language != null) {
|
||||
|
|
@ -146,7 +147,7 @@ class ItemPlaybackRepository
|
|||
subtitlesDisabled = true,
|
||||
)
|
||||
} else {
|
||||
val stream = source.mediaStreams?.first { it.index == trackIndex }
|
||||
val stream = source.mediaStreams?.firstOrNull { it.index == trackIndex }
|
||||
if (stream?.language != null) {
|
||||
streamChoiceService.updateSubtitles(
|
||||
item.data,
|
||||
|
|
@ -204,6 +205,18 @@ class ItemPlaybackRepository
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteChosenStreams(chosenStreams: ChosenStreams?) {
|
||||
Timber.d("deleteChosenStreams: %s", chosenStreams)
|
||||
chosenStreams?.plc?.let {
|
||||
Timber.d("Deleting %s", it)
|
||||
playbackLanguageChoiceDao.delete(it)
|
||||
}
|
||||
chosenStreams?.itemPlayback?.let {
|
||||
Timber.d("Deleting %s", it)
|
||||
itemPlaybackDao.deleteItem(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ChosenStreams(
|
||||
|
|
|
|||
|
|
@ -4,11 +4,13 @@ import android.content.Context
|
|||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
|
||||
import com.github.damontecres.wholphin.data.model.NavPinType
|
||||
import com.github.damontecres.wholphin.services.SeerrServerRepository
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
|
||||
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
|
||||
import com.github.damontecres.wholphin.util.supportedCollectionTypes
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||
|
|
@ -24,6 +26,7 @@ class NavDrawerItemRepository
|
|||
private val api: ApiClient,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val serverPreferencesDao: ServerPreferencesDao,
|
||||
private val seerrServerRepository: SeerrServerRepository,
|
||||
) {
|
||||
suspend fun getNavDrawerItems(): List<NavDrawerItem> {
|
||||
val user = serverRepository.currentUser.value
|
||||
|
|
@ -46,7 +49,13 @@ class NavDrawerItemRepository
|
|||
setOf()
|
||||
}
|
||||
|
||||
val builtins = listOf(NavDrawerItem.Favorites)
|
||||
val builtins =
|
||||
if (seerrServerRepository.active.first()) {
|
||||
listOf(NavDrawerItem.Favorites, NavDrawerItem.Discover)
|
||||
} else {
|
||||
listOf(NavDrawerItem.Favorites)
|
||||
}
|
||||
|
||||
val libraries =
|
||||
userViews
|
||||
.filter { it.collectionType in supportedCollectionTypes || it.id in recordingFolders }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.github.damontecres.wholphin.data
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
|
|
@ -17,4 +18,7 @@ interface PlaybackLanguageChoiceDao {
|
|||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun save(plc: PlaybackLanguageChoice): Long
|
||||
|
||||
@Delete
|
||||
fun delete(plc: PlaybackLanguageChoice)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
package com.github.damontecres.wholphin.data
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Update
|
||||
import com.github.damontecres.wholphin.data.model.SeerrServer
|
||||
import com.github.damontecres.wholphin.data.model.SeerrServerUsers
|
||||
import com.github.damontecres.wholphin.data.model.SeerrUser
|
||||
|
||||
@Dao
|
||||
interface SeerrServerDao {
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun addServer(server: SeerrServer): Long
|
||||
|
||||
@Update
|
||||
suspend fun updateServer(server: SeerrServer): Int
|
||||
|
||||
@Transaction
|
||||
suspend fun addOrUpdateServer(server: SeerrServer) {
|
||||
val result = addServer(server)
|
||||
if (result == -1L) {
|
||||
updateServer(server)
|
||||
}
|
||||
}
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun addUser(user: SeerrUser): Long
|
||||
|
||||
suspend fun updateUser(user: SeerrUser) = addUser(user)
|
||||
|
||||
@Query("SELECT * FROM seerr_users WHERE serverId = :serverId AND jellyfinUserRowId = :jellyfinUserRowId")
|
||||
suspend fun getUser(
|
||||
serverId: Int,
|
||||
jellyfinUserRowId: Int,
|
||||
): SeerrUser?
|
||||
|
||||
@Query("SELECT * FROM seerr_users WHERE jellyfinUserRowId = :jellyfinUserRowId")
|
||||
suspend fun getUsersByJellyfinUser(jellyfinUserRowId: Int): List<SeerrUser>
|
||||
|
||||
@Query("DELETE FROM seerr_servers WHERE id = :serverId")
|
||||
suspend fun deleteServer(serverId: Int)
|
||||
|
||||
@Query("DELETE FROM seerr_users WHERE serverId = :serverId AND jellyfinUserRowId = :jellyfinUserRowId")
|
||||
suspend fun deleteUser(
|
||||
serverId: Int,
|
||||
jellyfinUserRowId: Int,
|
||||
)
|
||||
|
||||
suspend fun deleteUser(user: SeerrUser) = deleteUser(user.serverId, user.jellyfinUserRowId)
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM seerr_servers")
|
||||
suspend fun getServers(): List<SeerrServerUsers>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM seerr_servers WHERE id = :serverId")
|
||||
suspend fun getServer(serverId: Int): SeerrServerUsers?
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM seerr_servers WHERE url = :url")
|
||||
suspend fun getServer(url: String): SeerrServerUsers?
|
||||
}
|
||||
|
|
@ -242,7 +242,6 @@ class ServerRepository
|
|||
}
|
||||
|
||||
suspend fun switchServerOrUser() {
|
||||
apiClient.update(baseUrl = null, accessToken = null)
|
||||
userPreferencesDataStore.updateData {
|
||||
it
|
||||
.toBuilder()
|
||||
|
|
|
|||
|
|
@ -1,27 +1,40 @@
|
|||
package com.github.damontecres.wholphin.data.model
|
||||
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import com.github.damontecres.wholphin.ui.DateFormatter
|
||||
import com.github.damontecres.wholphin.ui.detail.CardGridItem
|
||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
||||
import com.github.damontecres.wholphin.ui.dot
|
||||
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.roundMinutes
|
||||
import com.github.damontecres.wholphin.ui.seasonEpisode
|
||||
import com.github.damontecres.wholphin.ui.seasonEpisodePadded
|
||||
import com.github.damontecres.wholphin.ui.seriesProductionYears
|
||||
import com.github.damontecres.wholphin.ui.timeRemaining
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import java.util.Locale
|
||||
import kotlin.time.Duration
|
||||
|
||||
@Serializable
|
||||
@Stable
|
||||
data class BaseItem(
|
||||
val data: BaseItemDto,
|
||||
val useSeriesForPrimary: Boolean,
|
||||
) : CardGridItem {
|
||||
override val id get() = data.id
|
||||
val id get() = data.id
|
||||
|
||||
override val gridId get() = id.toString()
|
||||
|
||||
override val playable: Boolean
|
||||
get() = type.playable
|
||||
|
|
@ -69,6 +82,61 @@ data class BaseItem(
|
|||
|
||||
val favorite get() = data.userData?.isFavorite ?: false
|
||||
|
||||
@Transient
|
||||
val timeRemainingOrRuntime: Duration? = data.timeRemaining ?: data.runTimeTicks?.ticks
|
||||
|
||||
@Transient
|
||||
val ui =
|
||||
BaseItemUi(
|
||||
quickDetails =
|
||||
buildAnnotatedString {
|
||||
val details =
|
||||
buildList {
|
||||
if (type == BaseItemKind.EPISODE) {
|
||||
data.seasonEpisode?.let(::add)
|
||||
data.premiereDate?.let { add(DateFormatter.format(it)) }
|
||||
} else if (type == BaseItemKind.SERIES) {
|
||||
data.seriesProductionYears?.let(::add)
|
||||
} else {
|
||||
data.productionYear?.let { add(it.toString()) }
|
||||
}
|
||||
data.runTimeTicks
|
||||
?.ticks
|
||||
?.roundMinutes
|
||||
?.let { add(it.toString()) }
|
||||
data.timeRemaining
|
||||
?.roundMinutes
|
||||
?.let { add("$it left") }
|
||||
}
|
||||
details.forEachIndexed { index, string ->
|
||||
append(string)
|
||||
if (index != details.lastIndex) {
|
||||
dot()
|
||||
}
|
||||
}
|
||||
// TODO time remaining
|
||||
|
||||
data.officialRating?.let {
|
||||
dot()
|
||||
append(it)
|
||||
}
|
||||
data.communityRating?.let {
|
||||
dot()
|
||||
append(String.format(Locale.getDefault(), "%.1f", it))
|
||||
appendInlineContent(id = "star")
|
||||
}
|
||||
data.criticRating?.let {
|
||||
dot()
|
||||
append("${it.toInt()}%")
|
||||
if (it >= 60f) {
|
||||
appendInlineContent(id = "fresh")
|
||||
} else {
|
||||
appendInlineContent(id = "rotten")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
private fun dateAsIndex(): Int? =
|
||||
data.premiereDate
|
||||
?.let {
|
||||
|
|
@ -86,23 +154,21 @@ data class BaseItem(
|
|||
Destination.SeriesOverview(
|
||||
data.seriesId!!,
|
||||
BaseItemKind.SERIES,
|
||||
this,
|
||||
SeasonEpisodeIds(seasonId, data.parentIndexNumber, id, indexNumber),
|
||||
)
|
||||
} ?: Destination.MediaItem(id, type, this)
|
||||
} ?: Destination.MediaItem(this)
|
||||
}
|
||||
|
||||
BaseItemKind.SEASON -> {
|
||||
Destination.SeriesOverview(
|
||||
data.seriesId!!,
|
||||
BaseItemKind.SERIES,
|
||||
this,
|
||||
SeasonEpisodeIds(id, indexNumber, null, null),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Destination.MediaItem(id, type, this)
|
||||
Destination.MediaItem(this)
|
||||
}
|
||||
}
|
||||
return result
|
||||
|
|
@ -122,3 +188,8 @@ data class BaseItem(
|
|||
}
|
||||
|
||||
val BaseItemDto.aspectRatioFloat: Float? get() = width?.let { w -> height?.let { h -> w.toFloat() / h.toFloat() } }
|
||||
|
||||
@Immutable
|
||||
data class BaseItemUi(
|
||||
val quickDetails: AnnotatedString,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,226 @@
|
|||
@file:UseSerializers(UUIDSerializer::class)
|
||||
|
||||
package com.github.damontecres.wholphin.data.model
|
||||
|
||||
import com.github.damontecres.wholphin.api.seerr.model.CreditCast
|
||||
import com.github.damontecres.wholphin.api.seerr.model.CreditCrew
|
||||
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails
|
||||
import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingsGet200Response
|
||||
import com.github.damontecres.wholphin.api.seerr.model.MovieResult
|
||||
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
|
||||
import com.github.damontecres.wholphin.api.seerr.model.TvResult
|
||||
import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response
|
||||
import com.github.damontecres.wholphin.services.SeerrSearchResult
|
||||
import com.github.damontecres.wholphin.ui.detail.CardGridItem
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.toLocalDate
|
||||
import com.github.damontecres.wholphin.util.LocalDateSerializer
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.UseSerializers
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import java.time.LocalDate
|
||||
import java.util.UUID
|
||||
|
||||
@Serializable
|
||||
enum class SeerrItemType(
|
||||
val baseItemKind: BaseItemKind?,
|
||||
) {
|
||||
@SerialName("movie")
|
||||
MOVIE(BaseItemKind.MOVIE),
|
||||
|
||||
@SerialName("tv")
|
||||
TV(BaseItemKind.SERIES),
|
||||
|
||||
@SerialName("person")
|
||||
PERSON(BaseItemKind.PERSON),
|
||||
|
||||
@SerialName("unknown")
|
||||
UNKNOWN(null),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromString(
|
||||
str: String?,
|
||||
fallback: SeerrItemType = UNKNOWN,
|
||||
) = when (str) {
|
||||
"movie" -> MOVIE
|
||||
"tv" -> TV
|
||||
"person" -> PERSON
|
||||
else -> fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class SeerrAvailability(
|
||||
val status: Int,
|
||||
) {
|
||||
UNKNOWN(1),
|
||||
PENDING(2),
|
||||
PROCESSING(3),
|
||||
PARTIALLY_AVAILABLE(4),
|
||||
AVAILABLE(5),
|
||||
DELETED(6),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun from(status: Int?) = entries.firstOrNull { it.status == status }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An item provided by a discovery service (ie Seerr). It may exist on the JF server as well.
|
||||
*/
|
||||
@Serializable
|
||||
data class DiscoverItem(
|
||||
val id: Int,
|
||||
val type: SeerrItemType,
|
||||
val title: String?,
|
||||
val subtitle: String?,
|
||||
val overview: String?,
|
||||
val availability: SeerrAvailability,
|
||||
@Serializable(LocalDateSerializer::class) val releaseDate: LocalDate?,
|
||||
val posterPath: String?,
|
||||
val backdropPath: String?,
|
||||
val jellyfinItemId: UUID?,
|
||||
) : CardGridItem {
|
||||
override val gridId: String get() = id.toString()
|
||||
override val playable: Boolean = false
|
||||
override val sortName: String get() = title ?: ""
|
||||
|
||||
val backDropUrl: String? get() = backdropPath?.let { "https://image.tmdb.org/t/p/w1920_and_h1080_multi_faces$it" }
|
||||
val posterUrl: String? get() = posterPath?.let { "https://image.tmdb.org/t/p/w500$it" }
|
||||
|
||||
val destination: Destination
|
||||
get() {
|
||||
val jfType =
|
||||
when (type) {
|
||||
SeerrItemType.MOVIE -> BaseItemKind.MOVIE
|
||||
SeerrItemType.TV -> BaseItemKind.SERIES
|
||||
SeerrItemType.PERSON -> BaseItemKind.PERSON
|
||||
SeerrItemType.UNKNOWN -> null
|
||||
}
|
||||
return if (jellyfinItemId != null && jfType != null) {
|
||||
Destination.MediaItem(
|
||||
itemId = jellyfinItemId,
|
||||
type = jfType,
|
||||
)
|
||||
} else {
|
||||
Destination.DiscoveredItem(this)
|
||||
}
|
||||
}
|
||||
|
||||
constructor(movie: MovieResult) : this(
|
||||
id = movie.id,
|
||||
type = SeerrItemType.MOVIE,
|
||||
title = movie.title,
|
||||
subtitle = null,
|
||||
overview = movie.overview,
|
||||
availability = SeerrAvailability.from(movie.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN,
|
||||
releaseDate = toLocalDate(movie.releaseDate),
|
||||
posterPath = movie.posterPath,
|
||||
backdropPath = movie.backdropPath,
|
||||
jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
|
||||
)
|
||||
|
||||
constructor(movie: MovieDetails) : this(
|
||||
id = movie.id ?: -1,
|
||||
type = SeerrItemType.MOVIE,
|
||||
title = movie.title,
|
||||
subtitle = null,
|
||||
overview = movie.overview,
|
||||
availability = SeerrAvailability.from(movie.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN,
|
||||
releaseDate = toLocalDate(movie.releaseDate),
|
||||
posterPath = movie.posterPath,
|
||||
backdropPath = movie.backdropPath,
|
||||
jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
|
||||
)
|
||||
|
||||
constructor(tv: TvResult) : this(
|
||||
id = tv.id!!,
|
||||
type = SeerrItemType.TV,
|
||||
title = tv.name,
|
||||
subtitle = null,
|
||||
overview = tv.overview,
|
||||
availability = SeerrAvailability.from(tv.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN,
|
||||
releaseDate = toLocalDate(tv.firstAirDate),
|
||||
posterPath = tv.posterPath,
|
||||
backdropPath = tv.backdropPath,
|
||||
jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
|
||||
)
|
||||
|
||||
constructor(tv: TvDetails) : this(
|
||||
id = tv.id!!,
|
||||
type = SeerrItemType.TV,
|
||||
title = tv.name,
|
||||
subtitle = null,
|
||||
overview = tv.overview,
|
||||
availability = SeerrAvailability.from(tv.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN,
|
||||
releaseDate = toLocalDate(tv.firstAirDate),
|
||||
posterPath = tv.posterPath,
|
||||
backdropPath = tv.backdropPath,
|
||||
jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
|
||||
)
|
||||
|
||||
constructor(search: SeerrSearchResult) : this(
|
||||
id = search.id,
|
||||
type = SeerrItemType.fromString(search.mediaType),
|
||||
title = search.title ?: search.name,
|
||||
subtitle = null,
|
||||
overview = search.overview,
|
||||
availability =
|
||||
SeerrAvailability.from(search.mediaInfo?.status)
|
||||
?: SeerrAvailability.UNKNOWN,
|
||||
releaseDate = toLocalDate(search.releaseDate ?: search.firstAirDate),
|
||||
posterPath = search.posterPath,
|
||||
backdropPath = search.backdropPath,
|
||||
jellyfinItemId = search.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
|
||||
)
|
||||
|
||||
constructor(credit: CreditCast) : this(
|
||||
id = credit.id!!,
|
||||
type = SeerrItemType.fromString(credit.mediaType, SeerrItemType.PERSON),
|
||||
title = credit.name ?: credit.title,
|
||||
subtitle = credit.character,
|
||||
overview = credit.overview,
|
||||
availability =
|
||||
SeerrAvailability.from(credit.mediaInfo?.status)
|
||||
?: SeerrAvailability.UNKNOWN,
|
||||
releaseDate = toLocalDate(credit.firstAirDate),
|
||||
posterPath = credit.posterPath ?: credit.profilePath,
|
||||
backdropPath = credit.backdropPath,
|
||||
jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
|
||||
)
|
||||
|
||||
constructor(credit: CreditCrew) : this(
|
||||
id = credit.id!!,
|
||||
type = SeerrItemType.fromString(credit.mediaType, SeerrItemType.PERSON),
|
||||
title = credit.name ?: credit.title,
|
||||
subtitle = credit.job,
|
||||
overview = credit.overview,
|
||||
availability =
|
||||
SeerrAvailability.from(credit.mediaInfo?.status)
|
||||
?: SeerrAvailability.UNKNOWN,
|
||||
releaseDate = toLocalDate(credit.firstAirDate),
|
||||
posterPath = credit.posterPath ?: credit.profilePath,
|
||||
backdropPath = credit.backdropPath,
|
||||
jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
|
||||
)
|
||||
}
|
||||
|
||||
data class DiscoverRating(
|
||||
val criticRating: Int?,
|
||||
val audienceRating: Float?,
|
||||
) {
|
||||
constructor(rating: MovieMovieIdRatingsGet200Response) : this(
|
||||
criticRating = rating.criticsScore,
|
||||
audienceRating = rating.audienceScore?.div(10f),
|
||||
)
|
||||
constructor(rating: TvTvIdRatingsGet200Response) : this(
|
||||
criticRating = rating.criticsScore,
|
||||
audienceRating = null,
|
||||
)
|
||||
}
|
||||
|
|
@ -54,4 +54,9 @@ object TrackIndex {
|
|||
* The user has explicitly disabled the tracks (eg turned off subtitles)
|
||||
*/
|
||||
const val DISABLED = -2
|
||||
|
||||
/**
|
||||
* The user wants only forced subtitles (for foreign language dialogue, signs, etc.)
|
||||
*/
|
||||
const val ONLY_FORCED = -3
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.github.damontecres.wholphin.data.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
|
|
@ -7,6 +8,7 @@ import org.jellyfin.sdk.model.api.BaseItemPerson
|
|||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.PersonKind
|
||||
|
||||
@Stable
|
||||
data class Person(
|
||||
val id: UUID,
|
||||
val name: String?,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
package com.github.damontecres.wholphin.data.model
|
||||
|
||||
import com.github.damontecres.wholphin.services.SeerrUserConfig
|
||||
|
||||
enum class SeerrPermission(
|
||||
private val flag: Int,
|
||||
) {
|
||||
// Source: https://github.com/seerr-team/seerr/blob/develop/server/lib/permissions.ts
|
||||
NONE(0),
|
||||
ADMIN(2),
|
||||
MANAGE_SETTINGS(4),
|
||||
MANAGE_USERS(8),
|
||||
MANAGE_REQUESTS(16),
|
||||
REQUEST(32),
|
||||
VOTE(64),
|
||||
AUTO_APPROVE(128),
|
||||
AUTO_APPROVE_MOVIE(256),
|
||||
AUTO_APPROVE_TV(512),
|
||||
REQUEST_4K(1024),
|
||||
REQUEST_4K_MOVIE(2048),
|
||||
REQUEST_4K_TV(4096),
|
||||
REQUEST_ADVANCED(8192),
|
||||
REQUEST_VIEW(16384),
|
||||
AUTO_APPROVE_4K(32768),
|
||||
AUTO_APPROVE_4K_MOVIE(65536),
|
||||
AUTO_APPROVE_4K_TV(131072),
|
||||
REQUEST_MOVIE(262144),
|
||||
REQUEST_TV(524288),
|
||||
MANAGE_ISSUES(1048576),
|
||||
VIEW_ISSUES(2097152),
|
||||
CREATE_ISSUES(4194304),
|
||||
AUTO_REQUEST(8388608),
|
||||
AUTO_REQUEST_MOVIE(16777216),
|
||||
AUTO_REQUEST_TV(33554432),
|
||||
RECENT_VIEW(67108864),
|
||||
WATCHLIST_VIEW(134217728),
|
||||
MANAGE_BLACKLIST(268435456),
|
||||
VIEW_BLACKLIST(1073741824),
|
||||
;
|
||||
|
||||
internal fun hasPermission(permissions: Int) = flag.and(permissions) == flag
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the user has the given permissions (or is an admin)
|
||||
*/
|
||||
fun SeerrUserConfig?.hasPermission(permission: SeerrPermission): Boolean {
|
||||
return permission.hasPermission(this?.permissions ?: return false) || SeerrPermission.ADMIN.hasPermission(permissions)
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
@file:UseSerializers(UUIDSerializer::class)
|
||||
|
||||
package com.github.damontecres.wholphin.data.model
|
||||
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.Relation
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.UseSerializers
|
||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
|
||||
@Entity(
|
||||
tableName = "seerr_servers",
|
||||
indices = [Index("url", unique = true)],
|
||||
)
|
||||
@Serializable
|
||||
data class SeerrServer(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
val id: Int = 0,
|
||||
val url: String,
|
||||
val name: String? = null,
|
||||
val version: String? = null,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "seerr_users",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = SeerrServer::class,
|
||||
parentColumns = arrayOf("id"),
|
||||
childColumns = arrayOf("serverId"),
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
ForeignKey(
|
||||
entity = JellyfinUser::class,
|
||||
parentColumns = arrayOf("rowId"),
|
||||
childColumns = arrayOf("jellyfinUserRowId"),
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
primaryKeys = ["jellyfinUserRowId", "serverId"],
|
||||
)
|
||||
data class SeerrUser(
|
||||
val jellyfinUserRowId: Int,
|
||||
val serverId: Int,
|
||||
val authMethod: SeerrAuthMethod,
|
||||
val username: String?,
|
||||
val password: String?,
|
||||
val credential: String?,
|
||||
) {
|
||||
override fun toString(): String =
|
||||
"SeerrUser(jellyfinUserRowId=$jellyfinUserRowId, serverId=$serverId, authMethod=$authMethod, username=$username, password?=${password.isNotNullOrBlank()}, credential?=${credential.isNotNullOrBlank()})"
|
||||
}
|
||||
|
||||
enum class SeerrAuthMethod {
|
||||
LOCAL,
|
||||
JELLYFIN,
|
||||
API_KEY,
|
||||
}
|
||||
|
||||
data class SeerrServerUsers(
|
||||
@Embedded val server: SeerrServer,
|
||||
@Relation(
|
||||
parentColumn = "id",
|
||||
entityColumn = "serverId",
|
||||
)
|
||||
val users: List<SeerrUser>,
|
||||
)
|
||||
|
|
@ -14,4 +14,5 @@ data class LocalTrailer(
|
|||
data class RemoteTrailer(
|
||||
override val name: String,
|
||||
val url: String,
|
||||
val subtitle: String?,
|
||||
) : Trailer
|
||||
|
|
|
|||
|
|
@ -414,6 +414,29 @@ sealed interface AppPreference<Pref, T> {
|
|||
summaryOff = R.string.disabled,
|
||||
)
|
||||
|
||||
val DirectPlayDoviProfile7 =
|
||||
AppSwitchPreference<AppPreferences>(
|
||||
title = R.string.force_dovi_profile_7,
|
||||
defaultValue = false,
|
||||
getter = { it.playbackPreferences.overrides.directPlayDolbyVisionEL },
|
||||
setter = { prefs, value ->
|
||||
prefs.updatePlaybackOverrides { directPlayDolbyVisionEL = value }
|
||||
},
|
||||
summary = R.string.force_dovi_profile_7_summary,
|
||||
)
|
||||
|
||||
val DecodeAv1 =
|
||||
AppSwitchPreference<AppPreferences>(
|
||||
title = R.string.software_decoding_av1,
|
||||
defaultValue = true,
|
||||
getter = { it.playbackPreferences.overrides.decodeAv1 },
|
||||
setter = { prefs, value ->
|
||||
prefs.updatePlaybackOverrides { decodeAv1 = value }
|
||||
},
|
||||
summaryOn = R.string.enabled,
|
||||
summaryOff = R.string.disabled,
|
||||
)
|
||||
|
||||
val RememberSelectedTab =
|
||||
AppSwitchPreference<AppPreferences>(
|
||||
title = R.string.remember_selected_tab,
|
||||
|
|
@ -696,7 +719,25 @@ sealed interface AppPreference<Pref, T> {
|
|||
defaultValue = false,
|
||||
getter = { it.playbackPreferences.refreshRateSwitching },
|
||||
setter = { prefs, value ->
|
||||
prefs.updatePlaybackPreferences { refreshRateSwitching = value }
|
||||
prefs.updatePlaybackPreferences {
|
||||
if (!value) resolutionSwitching = false
|
||||
refreshRateSwitching = value
|
||||
}
|
||||
},
|
||||
summaryOn = R.string.automatic,
|
||||
summaryOff = R.string.disabled,
|
||||
)
|
||||
|
||||
val ResolutionSwitching =
|
||||
AppSwitchPreference<AppPreferences>(
|
||||
title = R.string.resolution_switching,
|
||||
defaultValue = false,
|
||||
getter = { it.playbackPreferences.resolutionSwitching },
|
||||
setter = { prefs, value ->
|
||||
prefs.updatePlaybackPreferences {
|
||||
if (value) refreshRateSwitching = true
|
||||
resolutionSwitching = value
|
||||
}
|
||||
},
|
||||
summaryOn = R.string.automatic,
|
||||
summaryOff = R.string.disabled,
|
||||
|
|
@ -842,6 +883,13 @@ sealed interface AppPreference<Pref, T> {
|
|||
summaryOn = R.string.enabled,
|
||||
summaryOff = R.string.disabled,
|
||||
)
|
||||
|
||||
val SeerrIntegration =
|
||||
AppClickablePreference<AppPreferences>(
|
||||
title = R.string.seerr_integration,
|
||||
getter = { },
|
||||
setter = { prefs, _ -> prefs },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -903,6 +951,7 @@ val basicPreferences =
|
|||
title = R.string.more,
|
||||
preferences =
|
||||
listOf(
|
||||
AppPreference.SeerrIntegration,
|
||||
AppPreference.AdvancedSettings,
|
||||
),
|
||||
),
|
||||
|
|
@ -934,6 +983,7 @@ val advancedPreferences =
|
|||
AppPreference.GlobalContentScale,
|
||||
AppPreference.MaxBitrate,
|
||||
AppPreference.RefreshRateSwitching,
|
||||
AppPreference.ResolutionSwitching,
|
||||
AppPreference.PlaybackDebugInfo,
|
||||
),
|
||||
),
|
||||
|
|
@ -965,6 +1015,8 @@ val advancedPreferences =
|
|||
AppPreference.Ac3Supported,
|
||||
AppPreference.DirectPlayAss,
|
||||
AppPreference.DirectPlayPgs,
|
||||
AppPreference.DirectPlayDoviProfile7,
|
||||
AppPreference.DecodeAv1,
|
||||
),
|
||||
),
|
||||
ConditionalPreferences(
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ class AppPreferencesSerializer
|
|||
playerBackend = AppPreference.PlayerBackendPref.defaultValue
|
||||
refreshRateSwitching =
|
||||
AppPreference.RefreshRateSwitching.defaultValue
|
||||
resolutionSwitching = AppPreference.ResolutionSwitching.defaultValue
|
||||
|
||||
overrides =
|
||||
PlaybackOverrides
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ 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.data.model.DiscoverItem
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.BackdropStyle
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
|
|
@ -27,7 +28,6 @@ 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
|
||||
|
||||
|
|
@ -47,27 +47,38 @@ class BackdropService
|
|||
|
||||
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)
|
||||
}
|
||||
val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)!!
|
||||
submit(item.id.toString(), imageUrl)
|
||||
}
|
||||
|
||||
suspend fun submit(item: DiscoverItem) = submit("discover_${item.id}", item.backDropUrl)
|
||||
|
||||
suspend fun submit(
|
||||
itemId: String,
|
||||
imageUrl: String?,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
if (backdropFlow.firstOrNull()?.imageUrl != imageUrl) {
|
||||
_backdropFlow.update {
|
||||
it.copy(
|
||||
itemId = itemId,
|
||||
imageUrl = null,
|
||||
)
|
||||
}
|
||||
extractColors(itemId, imageUrl)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearBackdrop() {
|
||||
_backdropFlow.update {
|
||||
BackdropResult.NONE
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun extractColors(item: BaseItem) {
|
||||
private suspend fun extractColors(
|
||||
itemId: String,
|
||||
imageUrl: String?,
|
||||
) {
|
||||
delay(500)
|
||||
val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
|
||||
val backdropStyle =
|
||||
preferences.data
|
||||
.firstOrNull()
|
||||
|
|
@ -83,9 +94,9 @@ class BackdropService
|
|||
ExtractedColors.DEFAULT
|
||||
}
|
||||
_backdropFlow.update {
|
||||
if (it.itemId == item.id) {
|
||||
if (it.itemId == itemId) {
|
||||
BackdropResult(
|
||||
itemId = item.id,
|
||||
itemId = itemId,
|
||||
imageUrl = imageUrl,
|
||||
primaryColor = primaryColor,
|
||||
secondaryColor = secondaryColor,
|
||||
|
|
@ -208,7 +219,7 @@ class BackdropService
|
|||
}
|
||||
|
||||
data class BackdropResult(
|
||||
val itemId: UUID?,
|
||||
val itemId: String?,
|
||||
val imageUrl: String?,
|
||||
val primaryColor: Color = Color.Unspecified,
|
||||
val secondaryColor: Color = Color.Unspecified,
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ class DeviceProfileService
|
|||
downMixAudio = prefs.overrides.downmixStereo,
|
||||
assDirectPlay = prefs.overrides.directPlayAss,
|
||||
pgsDirectPlay = prefs.overrides.directPlayPgs,
|
||||
dolbyVisionELDirectPlay = prefs.overrides.directPlayDolbyVisionEL,
|
||||
decodeAv1 = prefs.overrides.decodeAv1,
|
||||
jellyfinTenEleven =
|
||||
serverVersion != null && serverVersion >= ServerVersion(10, 11, 0),
|
||||
)
|
||||
|
|
@ -55,6 +57,8 @@ class DeviceProfileService
|
|||
downMixAudio = newConfig.downMixAudio,
|
||||
assDirectPlay = newConfig.assDirectPlay,
|
||||
pgsDirectPlay = newConfig.pgsDirectPlay,
|
||||
dolbyVisionELDirectPlay = newConfig.dolbyVisionELDirectPlay,
|
||||
decodeAv1 = prefs.overrides.decodeAv1,
|
||||
jellyfinTenEleven = newConfig.jellyfinTenEleven,
|
||||
)
|
||||
}
|
||||
|
|
@ -72,5 +76,7 @@ data class DeviceProfileConfiguration(
|
|||
val downMixAudio: Boolean,
|
||||
val assDirectPlay: Boolean,
|
||||
val pgsDirectPlay: Boolean,
|
||||
val dolbyVisionELDirectPlay: Boolean,
|
||||
val decodeAv1: Boolean,
|
||||
val jellyfinTenEleven: Boolean,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -28,10 +28,11 @@ class ImageUrlService
|
|||
itemType: BaseItemKind,
|
||||
seriesId: UUID?,
|
||||
useSeriesForPrimary: Boolean,
|
||||
imageTags: Map<ImageType, String?>,
|
||||
imageType: ImageType,
|
||||
fillWidth: Int? = null,
|
||||
fillHeight: Int? = null,
|
||||
): String =
|
||||
): String? =
|
||||
when (imageType) {
|
||||
ImageType.BACKDROP,
|
||||
ImageType.LOGO,
|
||||
|
|
@ -66,6 +67,13 @@ class ImageUrlService
|
|||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
)
|
||||
} else if (seriesId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) {
|
||||
getItemImageUrl(
|
||||
itemId = seriesId,
|
||||
imageType = imageType,
|
||||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
)
|
||||
} else {
|
||||
getItemImageUrl(
|
||||
itemId = itemId,
|
||||
|
|
@ -98,6 +106,7 @@ class ImageUrlService
|
|||
itemType = item.type,
|
||||
seriesId = item.data.seriesId,
|
||||
useSeriesForPrimary = item.useSeriesForPrimary,
|
||||
imageTags = item.data.imageTags.orEmpty(),
|
||||
imageType = imageType,
|
||||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
|
|
@ -124,8 +133,9 @@ class ImageUrlService
|
|||
backgroundColor: String? = null,
|
||||
foregroundLayer: String? = null,
|
||||
imageIndex: Int? = null,
|
||||
): String =
|
||||
api.imageApi.getItemImageUrl(
|
||||
): String? {
|
||||
if (api.baseUrl.isNullOrBlank()) return null
|
||||
return api.imageApi.getItemImageUrl(
|
||||
itemId = itemId,
|
||||
imageType = imageType,
|
||||
maxWidth = maxWidth,
|
||||
|
|
@ -144,6 +154,7 @@ class ImageUrlService
|
|||
foregroundLayer = foregroundLayer,
|
||||
imageIndex = imageIndex,
|
||||
)
|
||||
}
|
||||
|
||||
fun getUserImageUrl(userId: UUID) = api.imageApi.getUserImageUrl(userId)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,190 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
import com.github.damontecres.wholphin.ui.main.LatestData
|
||||
import com.github.damontecres.wholphin.ui.main.supportedLatestCollectionTypes
|
||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||
import com.github.damontecres.wholphin.util.supportItemKinds
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
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.api.client.extensions.itemsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.UserDto
|
||||
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
|
||||
import timber.log.Timber
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Singleton
|
||||
class LatestNextUpService
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val api: ApiClient,
|
||||
private val datePlayedService: DatePlayedService,
|
||||
) {
|
||||
suspend fun getResume(
|
||||
userId: UUID,
|
||||
limit: Int,
|
||||
includeEpisodes: Boolean,
|
||||
): List<BaseItem> {
|
||||
val request =
|
||||
GetResumeItemsRequest(
|
||||
userId = userId,
|
||||
fields = SlimItemFields,
|
||||
limit = limit,
|
||||
includeItemTypes =
|
||||
if (includeEpisodes) {
|
||||
supportItemKinds
|
||||
} else {
|
||||
supportItemKinds
|
||||
.toMutableSet()
|
||||
.apply {
|
||||
remove(BaseItemKind.EPISODE)
|
||||
}
|
||||
},
|
||||
)
|
||||
val items =
|
||||
api.itemsApi
|
||||
.getResumeItems(request)
|
||||
.content
|
||||
.items
|
||||
.map { BaseItem.from(it, api, true) }
|
||||
return items
|
||||
}
|
||||
|
||||
suspend fun getNextUp(
|
||||
userId: UUID,
|
||||
limit: Int,
|
||||
enableRewatching: Boolean,
|
||||
enableResumable: Boolean,
|
||||
): List<BaseItem> {
|
||||
val request =
|
||||
GetNextUpRequest(
|
||||
userId = userId,
|
||||
fields = SlimItemFields,
|
||||
imageTypeLimit = 1,
|
||||
parentId = null,
|
||||
limit = limit,
|
||||
enableResumable = enableResumable,
|
||||
enableUserData = true,
|
||||
enableRewatching = enableRewatching,
|
||||
)
|
||||
val nextUp =
|
||||
api.tvShowsApi
|
||||
.getNextUp(request)
|
||||
.content
|
||||
.items
|
||||
.map { BaseItem.from(it, api, true) }
|
||||
return nextUp
|
||||
}
|
||||
|
||||
suspend fun getLatest(
|
||||
user: UserDto,
|
||||
limit: Int,
|
||||
includedIds: List<UUID>,
|
||||
): List<LatestData> {
|
||||
val excluded = user.configuration?.latestItemsExcludes.orEmpty()
|
||||
val views by api.userViewsApi.getUserViews()
|
||||
val latestData =
|
||||
views.items
|
||||
.filter {
|
||||
it.id in includedIds && it.id !in excluded &&
|
||||
it.collectionType in supportedLatestCollectionTypes
|
||||
}.map { view ->
|
||||
val title =
|
||||
view.name?.let { context.getString(R.string.recently_added_in, it) }
|
||||
?: context.getString(R.string.recently_added)
|
||||
val request =
|
||||
GetLatestMediaRequest(
|
||||
fields = SlimItemFields,
|
||||
imageTypeLimit = 1,
|
||||
parentId = view.id,
|
||||
groupItems = true,
|
||||
limit = limit,
|
||||
isPlayed = null, // Server will handle user's preference
|
||||
)
|
||||
LatestData(title, request)
|
||||
}
|
||||
|
||||
return latestData
|
||||
}
|
||||
|
||||
suspend fun loadLatest(latestData: List<LatestData>): List<HomeRowLoadingState> {
|
||||
val rows =
|
||||
latestData.mapNotNull { (title, request) ->
|
||||
try {
|
||||
val latest =
|
||||
api.userLibraryApi
|
||||
.getLatestMedia(request)
|
||||
.content
|
||||
.map { BaseItem.from(it, api, true) }
|
||||
if (latest.isNotEmpty()) {
|
||||
HomeRowLoadingState.Success(
|
||||
title = title,
|
||||
items = latest,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Exception fetching %s", title)
|
||||
HomeRowLoadingState.Error(
|
||||
title = title,
|
||||
exception = ex,
|
||||
)
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
suspend fun buildCombined(
|
||||
resume: List<BaseItem>,
|
||||
nextUp: List<BaseItem>,
|
||||
): List<BaseItem> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val start = System.currentTimeMillis()
|
||||
val semaphore = Semaphore(3)
|
||||
val deferred =
|
||||
nextUp
|
||||
.filter { it.data.seriesId != null }
|
||||
.map { item ->
|
||||
async(Dispatchers.IO) {
|
||||
try {
|
||||
semaphore.withPermit {
|
||||
datePlayedService.getLastPlayed(item)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error fetching %s", item.id)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val nextUpLastPlayed = deferred.awaitAll()
|
||||
val timestamps = mutableMapOf<UUID, LocalDateTime?>()
|
||||
nextUp.map { it.id }.zip(nextUpLastPlayed).toMap(timestamps)
|
||||
resume.forEach { timestamps[it.id] = it.data.userData?.lastPlayedDate }
|
||||
val result = (resume + nextUp).sortedByDescending { timestamps[it.id] }
|
||||
val duration = (System.currentTimeMillis() - start).milliseconds
|
||||
Timber.v("buildCombined took %s", duration)
|
||||
return@withContext result
|
||||
}
|
||||
}
|
||||
|
|
@ -68,9 +68,20 @@ class NavigationManager
|
|||
log()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the backstack to the specified destination
|
||||
*/
|
||||
fun replace(destination: Destination) {
|
||||
while (backStack.size > 1) {
|
||||
backStack.removeLastOrNull()
|
||||
}
|
||||
backStack[0] = destination
|
||||
log()
|
||||
}
|
||||
|
||||
private fun log() {
|
||||
val dest = backStack.lastOrNull().toString()
|
||||
Timber.Forest.i("Current Destination: %s", dest)
|
||||
Timber.i("Current Destination: %s", dest)
|
||||
ACRA.errorReporter.putCustomData("destination", dest)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ class PlaybackLifecycleObserver
|
|||
private var wasPlaying: Boolean? = null
|
||||
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
val lastDest = navigationManager.backStack.lastOrNull()
|
||||
if (lastDest is Destination.Playback || lastDest is Destination.PlaybackList) {
|
||||
navigationManager.goBack()
|
||||
}
|
||||
wasPlaying = null
|
||||
}
|
||||
|
||||
|
|
@ -40,9 +44,6 @@ class PlaybackLifecycleObserver
|
|||
}
|
||||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
if (navigationManager.backStack.lastOrNull() is Destination.Playback) {
|
||||
navigationManager.goBack()
|
||||
}
|
||||
themeSongPlayer.stop()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,15 +3,22 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.Renderer
|
||||
import androidx.media3.exoplayer.RenderersFactory
|
||||
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.exoplayer.video.MediaCodecVideoRenderer
|
||||
import androidx.media3.exoplayer.video.VideoRendererEventListener
|
||||
import androidx.media3.extractor.DefaultExtractorsFactory
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
|
|
@ -27,6 +34,7 @@ import io.github.peerless2012.ass.media.type.AssRenderType
|
|||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import timber.log.Timber
|
||||
import java.lang.reflect.Constructor
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
|
|
@ -74,6 +82,7 @@ class PlayerFactory
|
|||
val directPlayAss =
|
||||
prefs?.overrides?.directPlayAss
|
||||
?: AppPreference.DirectPlayAss.defaultValue
|
||||
val decodeAv1 = prefs?.overrides?.decodeAv1 == true
|
||||
Timber.v("extensions=$extensions, directPlayAss=$directPlayAss")
|
||||
val rendererMode =
|
||||
when (extensions) {
|
||||
|
|
@ -85,7 +94,7 @@ class PlayerFactory
|
|||
val dataSourceFactory = DefaultDataSource.Factory(context)
|
||||
val extractorsFactory = DefaultExtractorsFactory()
|
||||
var renderersFactory: RenderersFactory =
|
||||
DefaultRenderersFactory(context)
|
||||
WholphinRenderersFactory(context, decodeAv1)
|
||||
.setEnableDecoderFallback(true)
|
||||
.setExtensionRendererMode(rendererMode)
|
||||
val mediaSourceFactory =
|
||||
|
|
@ -135,3 +144,73 @@ data class PlayerCreation(
|
|||
val player: Player,
|
||||
val assHandler: AssHandler? = null,
|
||||
)
|
||||
|
||||
// Code is adapted from https://github.com/androidx/media/blob/release/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/DefaultRenderersFactory.java#L436
|
||||
class WholphinRenderersFactory(
|
||||
context: Context,
|
||||
private val av1Enabled: Boolean,
|
||||
) : DefaultRenderersFactory(context) {
|
||||
override fun buildVideoRenderers(
|
||||
context: Context,
|
||||
extensionRendererMode: Int,
|
||||
mediaCodecSelector: MediaCodecSelector,
|
||||
enableDecoderFallback: Boolean,
|
||||
eventHandler: Handler,
|
||||
eventListener: VideoRendererEventListener,
|
||||
allowedVideoJoiningTimeMs: Long,
|
||||
out: ArrayList<Renderer>,
|
||||
) {
|
||||
var videoRendererBuilder =
|
||||
MediaCodecVideoRenderer
|
||||
.Builder(context)
|
||||
.setCodecAdapterFactory(codecAdapterFactory)
|
||||
.setMediaCodecSelector(mediaCodecSelector)
|
||||
.setAllowedJoiningTimeMs(allowedVideoJoiningTimeMs)
|
||||
.setEnableDecoderFallback(enableDecoderFallback)
|
||||
.setEventHandler(eventHandler)
|
||||
.setEventListener(eventListener)
|
||||
.setMaxDroppedFramesToNotify(MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY)
|
||||
.experimentalSetParseAv1SampleDependencies(false)
|
||||
.experimentalSetLateThresholdToDropDecoderInputUs(C.TIME_UNSET)
|
||||
if (Build.VERSION.SDK_INT >= 34) {
|
||||
videoRendererBuilder =
|
||||
videoRendererBuilder.experimentalSetEnableMediaCodecBufferDecodeOnlyFlag(
|
||||
false,
|
||||
)
|
||||
}
|
||||
out.add(videoRendererBuilder.build())
|
||||
|
||||
if (extensionRendererMode == EXTENSION_RENDERER_MODE_OFF) {
|
||||
return
|
||||
}
|
||||
var extensionRendererIndex = out.size
|
||||
if (extensionRendererMode == EXTENSION_RENDERER_MODE_PREFER) {
|
||||
extensionRendererIndex--
|
||||
}
|
||||
|
||||
if (av1Enabled) {
|
||||
try {
|
||||
val clazz = Class.forName("androidx.media3.decoder.av1.Libdav1dVideoRenderer")
|
||||
val constructor: Constructor<*> =
|
||||
clazz.getConstructor(
|
||||
Long::class.javaPrimitiveType,
|
||||
Handler::class.java,
|
||||
VideoRendererEventListener::class.java,
|
||||
Int::class.javaPrimitiveType,
|
||||
)
|
||||
val renderer =
|
||||
constructor.newInstance(
|
||||
allowedVideoJoiningTimeMs,
|
||||
eventHandler,
|
||||
eventListener,
|
||||
MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY,
|
||||
) as Renderer
|
||||
out.add(extensionRendererIndex++, renderer)
|
||||
Timber.i("Loaded Libdav1dVideoRenderer.")
|
||||
} catch (e: Exception) {
|
||||
// The extension is present, but instantiation failed.
|
||||
throw java.lang.IllegalStateException("Error instantiating AV1 extension", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,41 +32,61 @@ class RefreshRateService
|
|||
private val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
|
||||
private val originalMode = display.mode
|
||||
|
||||
val displayModes get() = display.supportedModes
|
||||
val supportedDisplayModes get() = display.supportedModes.orEmpty()
|
||||
|
||||
private val _refreshRateMode = EqualityMutableLiveData<Display.Mode>(originalMode)
|
||||
val refreshRateMode: LiveData<Display.Mode> = _refreshRateMode
|
||||
private val displayModes: List<DisplayMode> by lazy {
|
||||
display.supportedModes
|
||||
.orEmpty()
|
||||
.map { DisplayMode(it) }
|
||||
.sortedWith(
|
||||
compareByDescending<DisplayMode>({ it.physicalWidth * it.physicalHeight })
|
||||
.thenBy { it.refreshRateRounded },
|
||||
)
|
||||
}
|
||||
|
||||
private val _refreshRateMode = EqualityMutableLiveData<Int>(originalMode.modeId)
|
||||
val refreshRateMode: LiveData<Int> = _refreshRateMode
|
||||
|
||||
/**
|
||||
* Find the best display mode for the given stream and signal to change to it
|
||||
*/
|
||||
suspend fun changeRefreshRate(stream: MediaStream) {
|
||||
suspend fun changeRefreshRate(
|
||||
stream: MediaStream,
|
||||
switchRefreshRate: Boolean,
|
||||
switchResolution: Boolean,
|
||||
) {
|
||||
if (!switchRefreshRate && !switchResolution) {
|
||||
Timber.v("Not switching either refresh rate nor resolution")
|
||||
return
|
||||
}
|
||||
val currentDisplayMode = display.mode
|
||||
require(stream.type == MediaStreamType.VIDEO) { "Stream is not video" }
|
||||
val width = stream.width
|
||||
val height = stream.height
|
||||
val frameRate = stream.realFrameRate?.times(100)?.roundToInt()
|
||||
val frameRate =
|
||||
if (switchRefreshRate) stream.realFrameRate else currentDisplayMode.refreshRate
|
||||
if (width == null || height == null || frameRate == null) {
|
||||
Timber.w("Video stream missing required info: width=%s, height=%s, frameRate=%s", width, height, frameRate)
|
||||
return
|
||||
}
|
||||
Timber.d("Getting refresh rate for: width=%s, height=%s, frameRate=%s", width, height, frameRate)
|
||||
val targetMode =
|
||||
display.supportedModes
|
||||
.filterNot { it.physicalHeight < height || it.physicalWidth < width }
|
||||
.filter {
|
||||
(it.refreshRate * 100).roundToInt().let { modeRate ->
|
||||
frameRate % modeRate == 0 || // Exact multiple
|
||||
modeRate == (frameRate * 2.5).roundToInt() // eg 24 & 60fps
|
||||
}
|
||||
}.maxByOrNull { it.physicalWidth * it.physicalHeight }
|
||||
Timber.i("Found display mode: %s, current=${display.mode}", targetMode)
|
||||
if (targetMode != null && targetMode != display.mode) {
|
||||
findDisplayMode(
|
||||
displayModes = displayModes,
|
||||
streamWidth = width,
|
||||
streamHeight = height,
|
||||
targetFrameRate = frameRate,
|
||||
refreshRateSwitch = switchRefreshRate,
|
||||
resolutionSwitch = switchResolution,
|
||||
)
|
||||
Timber.i("Found display mode: %s, current=%s", targetMode, currentDisplayMode)
|
||||
if (targetMode != null && targetMode.modeId != currentDisplayMode.modeId) {
|
||||
val listener = Listener(display.displayId)
|
||||
displayManager.registerDisplayListener(
|
||||
listener,
|
||||
Handler(Looper.myLooper() ?: Looper.getMainLooper()),
|
||||
)
|
||||
_refreshRateMode.setValueOnMain(targetMode)
|
||||
_refreshRateMode.setValueOnMain(targetMode.modeId)
|
||||
try {
|
||||
if (!listener.latch.await(5, TimeUnit.SECONDS)) {
|
||||
Timber.w("Timed out waiting for display change")
|
||||
|
|
@ -75,13 +95,13 @@ class RefreshRateService
|
|||
} catch (ex: InterruptedException) {
|
||||
Timber.w(ex, "Exception waiting for refresh rate switch")
|
||||
}
|
||||
val targetRate = (targetMode.refreshRate * 100).roundToInt()
|
||||
val targetRate = (targetMode.refreshRate * 1000).roundToInt()
|
||||
val isSeamless =
|
||||
targetRate == (display.mode.refreshRate * 100).roundToInt() ||
|
||||
targetRate == (currentDisplayMode.refreshRate * 1000).roundToInt() ||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
display.mode.alternativeRefreshRates
|
||||
.map { (it * 100).roundToInt() }
|
||||
.any { targetRate % it == 0 }
|
||||
currentDisplayMode.alternativeRefreshRates
|
||||
.map { (it * 1000).roundToInt() }
|
||||
.any { it % targetRate == 0 }
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
|
@ -98,7 +118,7 @@ class RefreshRateService
|
|||
* Reset the display mode to the original
|
||||
*/
|
||||
fun resetRefreshRate() {
|
||||
_refreshRateMode.value = originalMode
|
||||
_refreshRateMode.value = originalMode.modeId
|
||||
}
|
||||
|
||||
private class Listener(
|
||||
|
|
@ -119,4 +139,69 @@ class RefreshRateService
|
|||
override fun onDisplayRemoved(displayId: Int) {
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Find the best display mode for the given stream & preferences
|
||||
*
|
||||
* @param displayModes candidates that are sorted by resolution and frame rate descending
|
||||
*/
|
||||
fun findDisplayMode(
|
||||
displayModes: List<DisplayMode>,
|
||||
streamWidth: Int,
|
||||
streamHeight: Int,
|
||||
targetFrameRate: Float,
|
||||
refreshRateSwitch: Boolean,
|
||||
resolutionSwitch: Boolean,
|
||||
): DisplayMode? {
|
||||
val streamRate = targetFrameRate.times(1000).roundToInt()
|
||||
// Timber.v("display modes: %s", displayModes.joinToString("\n"))
|
||||
val candidates =
|
||||
if (refreshRateSwitch) {
|
||||
displayModes
|
||||
.filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth }
|
||||
.filter {
|
||||
it.refreshRateRounded % streamRate == 0 || // Exact multiple
|
||||
it.refreshRateRounded == (streamRate * 2.5).roundToInt() // eg 24 & 60fps
|
||||
}
|
||||
} else {
|
||||
displayModes
|
||||
.filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth }
|
||||
}
|
||||
// Timber.v("display modes candidates: %s", candidates.joinToString("\n"))
|
||||
return if (!resolutionSwitch) {
|
||||
candidates.maxByOrNull { it.physicalWidth * it.physicalHeight }
|
||||
} else {
|
||||
candidates.firstOrNull {
|
||||
it.physicalWidth == streamWidth &&
|
||||
it.physicalHeight == streamHeight &&
|
||||
it.refreshRateRounded == streamRate
|
||||
}
|
||||
?: candidates.firstOrNull {
|
||||
it.physicalWidth == streamWidth &&
|
||||
it.physicalHeight >= streamHeight &&
|
||||
it.refreshRateRounded == streamRate
|
||||
}
|
||||
?: candidates
|
||||
.filter { it.refreshRateRounded == streamRate }
|
||||
.maxByOrNull { it.physicalWidth * it.physicalHeight }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class DisplayMode(
|
||||
val modeId: Int,
|
||||
val physicalWidth: Int,
|
||||
val physicalHeight: Int,
|
||||
val refreshRate: Float,
|
||||
) {
|
||||
val refreshRateRounded: Int = (refreshRate * 1000).roundToInt()
|
||||
|
||||
constructor(mode: Display.Mode) : this(
|
||||
mode.modeId,
|
||||
mode.physicalWidth,
|
||||
mode.physicalHeight,
|
||||
mode.refreshRate,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
/**
|
||||
* Wrapper for [SeerrApiClient]. In most cases, you should use [SeerrService] instead.
|
||||
*/
|
||||
class SeerrApi(
|
||||
private val okHttpClient: OkHttpClient,
|
||||
) {
|
||||
var api: SeerrApiClient =
|
||||
SeerrApiClient(
|
||||
baseUrl = "",
|
||||
apiKey = null,
|
||||
okHttpClient = okHttpClient,
|
||||
)
|
||||
private set
|
||||
|
||||
val active: Boolean get() = api.baseUrl.isNotNullOrBlank()
|
||||
|
||||
fun update(
|
||||
baseUrl: String,
|
||||
apiKey: String?,
|
||||
) {
|
||||
api = SeerrApiClient(baseUrl, apiKey, okHttpClient)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.asFlow
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
|
||||
import com.github.damontecres.wholphin.api.seerr.model.AuthJellyfinPostRequest
|
||||
import com.github.damontecres.wholphin.api.seerr.model.AuthLocalPostRequest
|
||||
import com.github.damontecres.wholphin.api.seerr.model.PublicSettings
|
||||
import com.github.damontecres.wholphin.api.seerr.model.User
|
||||
import com.github.damontecres.wholphin.data.SeerrServerDao
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
|
||||
import com.github.damontecres.wholphin.data.model.SeerrPermission
|
||||
import com.github.damontecres.wholphin.data.model.SeerrServer
|
||||
import com.github.damontecres.wholphin.data.model.SeerrUser
|
||||
import com.github.damontecres.wholphin.data.model.hasPermission
|
||||
import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import dagger.hilt.android.qualifiers.ActivityContext
|
||||
import dagger.hilt.android.scopes.ActivityScoped
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import okhttp3.OkHttpClient
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Manages saves/loading Seerr servers from the local DB. Also will update the current [SeerrApi] as needed.
|
||||
*/
|
||||
@Singleton
|
||||
class SeerrServerRepository
|
||||
@Inject
|
||||
constructor(
|
||||
private val seerrApi: SeerrApi,
|
||||
private val seerrServerDao: SeerrServerDao,
|
||||
private val serverRepository: ServerRepository,
|
||||
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
|
||||
) {
|
||||
private val _current = MutableStateFlow<CurrentSeerr?>(null)
|
||||
val current: StateFlow<CurrentSeerr?> = _current
|
||||
val currentServer: Flow<SeerrServer?> = current.map { it?.server }
|
||||
val currentUser: Flow<SeerrUser?> = current.map { it?.user }
|
||||
|
||||
/**
|
||||
* Whether Seerr integration is currently active of not
|
||||
*/
|
||||
val active: Flow<Boolean> = current.map { it != null && seerrApi.active }
|
||||
|
||||
fun clear() {
|
||||
_current.update { null }
|
||||
seerrApi.update("", null)
|
||||
}
|
||||
|
||||
suspend fun set(
|
||||
server: SeerrServer,
|
||||
user: SeerrUser,
|
||||
userConfig: SeerrUserConfig,
|
||||
) {
|
||||
val publicSettings = seerrApi.api.settingsApi.settingsPublicGet()
|
||||
_current.update {
|
||||
CurrentSeerr(server, user, userConfig, publicSettings)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addAndChangeServer(
|
||||
url: String,
|
||||
apiKey: String,
|
||||
) {
|
||||
var server = seerrServerDao.getServer(url)
|
||||
if (server == null) {
|
||||
seerrServerDao.addServer(SeerrServer(url = url))
|
||||
server = seerrServerDao.getServer(url)
|
||||
}
|
||||
server?.server?.let { server ->
|
||||
serverRepository.currentUser.value?.let { jellyfinUser ->
|
||||
// TODO test api key
|
||||
val user =
|
||||
SeerrUser(
|
||||
jellyfinUserRowId = jellyfinUser.rowId,
|
||||
serverId = server.id,
|
||||
authMethod = SeerrAuthMethod.API_KEY,
|
||||
username = null,
|
||||
password = null,
|
||||
credential = apiKey,
|
||||
)
|
||||
seerrServerDao.addUser(user)
|
||||
|
||||
seerrApi.update(server.url, apiKey)
|
||||
val userConfig = seerrApi.api.usersApi.authMeGet()
|
||||
set(server, user, userConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addAndChangeServer(
|
||||
url: String,
|
||||
authMethod: SeerrAuthMethod,
|
||||
username: String,
|
||||
password: String,
|
||||
) {
|
||||
var server = seerrServerDao.getServer(url)
|
||||
if (server == null) {
|
||||
seerrServerDao.addServer(SeerrServer(url = url))
|
||||
server = seerrServerDao.getServer(url)
|
||||
}
|
||||
server?.server?.let { server ->
|
||||
serverRepository.currentUser.value?.let { jellyfinUser ->
|
||||
// TODO Need to update server early so that cookies are saved
|
||||
seerrApi.update(server.url, null)
|
||||
val userConfig = login(seerrApi.api, authMethod, username, password)
|
||||
|
||||
val user =
|
||||
SeerrUser(
|
||||
jellyfinUserRowId = jellyfinUser.rowId,
|
||||
serverId = server.id,
|
||||
authMethod = authMethod,
|
||||
username = username,
|
||||
password = password,
|
||||
credential = null,
|
||||
)
|
||||
seerrServerDao.addUser(user)
|
||||
set(server, user, userConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun testConnection(
|
||||
authMethod: SeerrAuthMethod,
|
||||
url: String,
|
||||
username: String?,
|
||||
passwordOrApiKey: String,
|
||||
): LoadingState {
|
||||
val api = SeerrApiClient(url, passwordOrApiKey, okHttpClient)
|
||||
login(api, authMethod, username, passwordOrApiKey)
|
||||
return LoadingState.Success
|
||||
}
|
||||
|
||||
suspend fun removeServer() {
|
||||
val current = _current.value ?: return
|
||||
seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId)
|
||||
clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [SeerrUser] config
|
||||
*/
|
||||
typealias SeerrUserConfig = User
|
||||
|
||||
data class CurrentSeerr(
|
||||
val server: SeerrServer,
|
||||
val user: SeerrUser,
|
||||
val config: SeerrUserConfig,
|
||||
val serverConfig: PublicSettings,
|
||||
) {
|
||||
val request4kMovieEnabled: Boolean
|
||||
get() =
|
||||
(serverConfig.movie4kEnabled ?: false) &&
|
||||
config.hasPermission(SeerrPermission.REQUEST_4K_MOVIE)
|
||||
|
||||
val request4kTvEnabled: Boolean
|
||||
get() =
|
||||
(serverConfig.series4kEnabled ?: false) &&
|
||||
config.hasPermission(SeerrPermission.REQUEST_4K_TV)
|
||||
}
|
||||
|
||||
private suspend fun login(
|
||||
client: SeerrApiClient,
|
||||
authMethod: SeerrAuthMethod,
|
||||
username: String?,
|
||||
password: String?,
|
||||
): User =
|
||||
when (authMethod) {
|
||||
SeerrAuthMethod.LOCAL -> {
|
||||
client.authApi.authLocalPost(
|
||||
AuthLocalPostRequest(
|
||||
email = username ?: "",
|
||||
password = password ?: "",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
SeerrAuthMethod.JELLYFIN -> {
|
||||
client.authApi.authJellyfinPost(
|
||||
AuthJellyfinPostRequest(
|
||||
username = username ?: "",
|
||||
password = password ?: "",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
SeerrAuthMethod.API_KEY -> {
|
||||
client.usersApi.authMeGet()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listens for JF user switching in the app to also switch the Seerr user/server
|
||||
*/
|
||||
@ActivityScoped
|
||||
class UserSwitchListener
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ActivityContext private val context: Context,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val seerrServerRepository: SeerrServerRepository,
|
||||
private val seerrServerDao: SeerrServerDao,
|
||||
private val seerrApi: SeerrApi,
|
||||
) {
|
||||
init {
|
||||
context as AppCompatActivity
|
||||
context.lifecycleScope.launchIO {
|
||||
serverRepository.currentUser.asFlow().collect { user ->
|
||||
Timber.d("New user")
|
||||
seerrServerRepository.clear()
|
||||
if (user != null) {
|
||||
seerrServerDao
|
||||
.getUsersByJellyfinUser(user.rowId)
|
||||
.firstOrNull()
|
||||
?.let { seerrUser ->
|
||||
val server = seerrServerDao.getServer(seerrUser.serverId)?.server
|
||||
if (server != null) {
|
||||
Timber.i("Found a seerr user & server")
|
||||
seerrApi.update(server.url, seerrUser.credential)
|
||||
val userConfig =
|
||||
if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
|
||||
try {
|
||||
login(
|
||||
seerrApi.api,
|
||||
seerrUser.authMethod,
|
||||
seerrUser.username,
|
||||
seerrUser.password,
|
||||
)
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(ex, "Error logging into %s", server.url)
|
||||
seerrServerRepository.clear()
|
||||
return@let
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
seerrApi.api.usersApi.authMeGet()
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(ex, "Error logging into %s", server.url)
|
||||
seerrServerRepository.clear()
|
||||
return@let
|
||||
}
|
||||
}
|
||||
seerrServerRepository.set(server, seerrUser, userConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
|
||||
import com.github.damontecres.wholphin.api.seerr.model.SearchGet200ResponseResultsInner
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
typealias SeerrSearchResult = SearchGet200ResponseResultsInner
|
||||
|
||||
/**
|
||||
* Main access for the current Seerr server (if any)
|
||||
*
|
||||
* Exposes a [SeerrApiClient] for queries
|
||||
*/
|
||||
@Singleton
|
||||
class SeerrService
|
||||
@Inject
|
||||
constructor(
|
||||
private val seerApi: SeerrApi,
|
||||
private val seerrServerRepository: SeerrServerRepository,
|
||||
) {
|
||||
val api: SeerrApiClient get() = seerApi.api
|
||||
|
||||
val active get() = seerrServerRepository.active
|
||||
|
||||
suspend fun search(
|
||||
query: String,
|
||||
page: Int = 1,
|
||||
): List<SeerrSearchResult> =
|
||||
api.searchApi
|
||||
.searchGet(query = query, page = page)
|
||||
.results
|
||||
.orEmpty()
|
||||
|
||||
suspend fun discoverTv(page: Int = 1): List<DiscoverItem> =
|
||||
api.searchApi
|
||||
.discoverTvGet(page = page)
|
||||
.results
|
||||
?.map(::DiscoverItem)
|
||||
.orEmpty()
|
||||
|
||||
suspend fun discoverMovies(page: Int = 1): List<DiscoverItem> =
|
||||
api.searchApi
|
||||
.discoverMoviesGet(page = page)
|
||||
.results
|
||||
?.map(::DiscoverItem)
|
||||
.orEmpty()
|
||||
|
||||
suspend fun trending(page: Int = 1): List<DiscoverItem> =
|
||||
api.searchApi
|
||||
.discoverTrendingGet(page = page)
|
||||
.results
|
||||
?.map(::DiscoverItem)
|
||||
.orEmpty()
|
||||
|
||||
suspend fun upcomingMovies(page: Int = 1): List<DiscoverItem> =
|
||||
api.searchApi
|
||||
.discoverMoviesUpcomingGet(page = page)
|
||||
.results
|
||||
?.map(::DiscoverItem)
|
||||
.orEmpty()
|
||||
|
||||
suspend fun upcomingTv(page: Int = 1): List<DiscoverItem> =
|
||||
api.searchApi
|
||||
.discoverTvUpcomingGet(page = page)
|
||||
.results
|
||||
?.map(::DiscoverItem)
|
||||
.orEmpty()
|
||||
|
||||
/**
|
||||
* Get [DiscoverItem]s similar to the JF items such as movies, series, or people
|
||||
*
|
||||
* If Seerr integration is not active, this short circuits to return null
|
||||
*
|
||||
* @return the discovered items or null if no Seerr server configured
|
||||
*/
|
||||
suspend fun similar(item: BaseItem): List<DiscoverItem>? =
|
||||
if (active.first()) {
|
||||
item.data.providerIds
|
||||
?.get("Tmdb")
|
||||
?.toIntOrNull()
|
||||
?.let {
|
||||
when (item.type) {
|
||||
BaseItemKind.MOVIE -> {
|
||||
api.moviesApi
|
||||
.movieMovieIdSimilarGet(movieId = it)
|
||||
.results
|
||||
?.map(::DiscoverItem)
|
||||
}
|
||||
|
||||
BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE -> {
|
||||
api.tvApi
|
||||
.tvTvIdSimilarGet(tvId = it)
|
||||
.results
|
||||
?.map(::DiscoverItem)
|
||||
}
|
||||
|
||||
BaseItemKind.PERSON -> {
|
||||
api.personApi
|
||||
.personPersonIdCombinedCreditsGet(personId = it)
|
||||
.let { credits ->
|
||||
val cast =
|
||||
credits.cast
|
||||
?.take(25)
|
||||
?.map(::DiscoverItem)
|
||||
.orEmpty()
|
||||
val crew =
|
||||
credits.crew
|
||||
?.take(25)
|
||||
?.map(::DiscoverItem)
|
||||
.orEmpty()
|
||||
cast + crew
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
}.orEmpty()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
|
@ -153,7 +153,7 @@ class StreamChoiceService
|
|||
return source.mediaStreams?.letNotEmpty { streams ->
|
||||
val candidates = streams.filter { it.type == MediaStreamType.SUBTITLE }
|
||||
chooseSubtitleStream(
|
||||
audioStream,
|
||||
audioStream?.language,
|
||||
candidates,
|
||||
itemPlayback,
|
||||
plc,
|
||||
|
|
@ -162,8 +162,42 @@ class StreamChoiceService
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves ONLY_FORCED to an actual subtitle track index.
|
||||
* Returns the original index if not ONLY_FORCED or DISABLED.
|
||||
*/
|
||||
suspend fun resolveSubtitleIndex(
|
||||
source: MediaSourceInfo,
|
||||
audioStreamIndex: Int?,
|
||||
seriesId: UUID?,
|
||||
subtitleIndex: Int,
|
||||
prefs: UserPreferences,
|
||||
): Int? =
|
||||
if (subtitleIndex != TrackIndex.ONLY_FORCED) {
|
||||
subtitleIndex
|
||||
} else {
|
||||
val audioStream =
|
||||
source.mediaStreams?.firstOrNull {
|
||||
it.type == MediaStreamType.AUDIO && it.index == audioStreamIndex
|
||||
}
|
||||
val itemPlayback =
|
||||
ItemPlayback(
|
||||
userId = serverRepository.currentUser.value!!.rowId,
|
||||
itemId = UUID.randomUUID(), // Not used for ONLY_FORCED resolution
|
||||
subtitleIndex = TrackIndex.ONLY_FORCED,
|
||||
)
|
||||
chooseSubtitleStream(
|
||||
source = source,
|
||||
audioStream = audioStream,
|
||||
seriesId = seriesId,
|
||||
itemPlayback = itemPlayback,
|
||||
plc = null,
|
||||
prefs = prefs,
|
||||
)?.index
|
||||
}
|
||||
|
||||
fun chooseSubtitleStream(
|
||||
audioStream: MediaStream?,
|
||||
audioStreamLang: String?,
|
||||
candidates: List<MediaStream>,
|
||||
itemPlayback: ItemPlayback?,
|
||||
playbackLanguageChoice: PlaybackLanguageChoice?,
|
||||
|
|
@ -171,6 +205,14 @@ class StreamChoiceService
|
|||
): MediaStream? {
|
||||
if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) {
|
||||
return null
|
||||
} else if (itemPlayback?.subtitleIndex == TrackIndex.ONLY_FORCED) {
|
||||
// Client-side manual override: User selected "Only Forced" in player menu
|
||||
val seriesLang =
|
||||
playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() }
|
||||
val subtitleLanguage =
|
||||
(seriesLang ?: userConfig?.subtitleLanguagePreference)
|
||||
?.takeIf { it.isNotNullOrBlank() }
|
||||
return findForcedTrack(candidates, subtitleLanguage, audioStreamLang)
|
||||
} else if (itemPlayback?.subtitleIndexEnabled == true) {
|
||||
return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex }
|
||||
} else {
|
||||
|
|
@ -198,48 +240,61 @@ class StreamChoiceService
|
|||
userConfig?.subtitleMode ?: SubtitlePlaybackMode.DEFAULT
|
||||
}
|
||||
}
|
||||
val candidates =
|
||||
candidates
|
||||
.sortedWith(
|
||||
compareByDescending<MediaStream> { it.isExternal }
|
||||
.thenByDescending { it.isDefault }
|
||||
.thenByDescending {
|
||||
!it.isForced && it.language.equals(subtitleLanguage, true)
|
||||
}.thenByDescending {
|
||||
it.isForced && it.language.equals(subtitleLanguage, true)
|
||||
}.thenByDescending { it.isForced && it.language.isUnknown }
|
||||
.thenByDescending { it.isForced },
|
||||
)
|
||||
return when (subtitleMode) {
|
||||
SubtitlePlaybackMode.ALWAYS -> {
|
||||
if (subtitleLanguage != null) {
|
||||
candidates.firstOrNull { it.language == subtitleLanguage }
|
||||
if (subtitleLanguage.isNotNullOrBlank()) {
|
||||
candidates.firstOrNull {
|
||||
it.language.equals(subtitleLanguage, true) ||
|
||||
it.language.isUnknown
|
||||
}
|
||||
} else {
|
||||
candidates.firstOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
SubtitlePlaybackMode.ONLY_FORCED -> {
|
||||
if (subtitleLanguage != null) {
|
||||
if (subtitleLanguage.isNotNullOrBlank()) {
|
||||
candidates.firstOrNull { it.language == subtitleLanguage && it.isForced }
|
||||
?: candidates.firstOrNull { it.language.isUnknown && it.isForced }
|
||||
} else {
|
||||
candidates.firstOrNull { it.isForced }
|
||||
}
|
||||
}
|
||||
|
||||
SubtitlePlaybackMode.SMART -> {
|
||||
val audioLanguage = userConfig?.audioLanguagePreference
|
||||
val audioStreamLang = audioStream?.language
|
||||
if (audioLanguage.isNotNullOrBlank() && audioStreamLang.isNotNullOrBlank() && audioLanguage != audioStreamLang) {
|
||||
candidates.firstOrNull { it.language == subtitleLanguage }
|
||||
if (subtitleLanguage.isNotNullOrBlank()) {
|
||||
val audioLanguage = userConfig?.audioLanguagePreference
|
||||
if (audioLanguage.isNullOrBlank() || audioLanguage != audioStreamLang) {
|
||||
candidates.firstOrNull { it.language == subtitleLanguage }
|
||||
?: candidates.firstOrNull { it.language.isUnknown }
|
||||
} else {
|
||||
candidates.firstOrNull { it.isForced && it.language == subtitleLanguage }
|
||||
?: candidates.firstOrNull { it.isForced && it.language.isUnknown }
|
||||
}
|
||||
} else {
|
||||
null
|
||||
candidates.firstOrNull { it.isDefault }
|
||||
}
|
||||
}
|
||||
|
||||
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 (subtitleLanguage.isNotNullOrBlank()) {
|
||||
candidates.firstOrNull { it.language == subtitleLanguage && (it.isDefault || it.isForced) }
|
||||
?: candidates.firstOrNull { it.isDefault || it.isForced }
|
||||
} else {
|
||||
candidates.firstOrNull { it.isDefault || it.isForced }
|
||||
}
|
||||
?: (
|
||||
// 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 -> {
|
||||
|
|
@ -248,4 +303,44 @@ class StreamChoiceService
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true if the track is forced (via metadata flag or title patterns). */
|
||||
private fun isForcedOrSigns(track: MediaStream): Boolean {
|
||||
if (track.isForced) return true
|
||||
val title = track.title ?: track.displayTitle ?: return false
|
||||
return title.contains("forced", ignoreCase = true) ||
|
||||
title.contains("signs", ignoreCase = true) ||
|
||||
title.contains("songs", ignoreCase = true)
|
||||
}
|
||||
|
||||
/** Finds a forced/signs track: subtitle pref -> audio -> unknown -> null. */
|
||||
private fun findForcedTrack(
|
||||
candidates: List<MediaStream>,
|
||||
subtitleLanguage: String?,
|
||||
audioLanguage: String?,
|
||||
): MediaStream? {
|
||||
// 1. User's preferred subtitle language
|
||||
if (subtitleLanguage != null) {
|
||||
candidates
|
||||
.firstOrNull { it.language.equals(subtitleLanguage, true) && isForcedOrSigns(it) }
|
||||
?.let { return it }
|
||||
}
|
||||
// 2. Audio language (for sign-subtitles if no preference match)
|
||||
if (audioLanguage != null) {
|
||||
candidates
|
||||
.firstOrNull { it.language.equals(audioLanguage, true) && isForcedOrSigns(it) }
|
||||
?.let { return it }
|
||||
}
|
||||
// 3. Unknown language forced track
|
||||
return candidates.firstOrNull { it.language.isUnknown && isForcedOrSigns(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private val String?.isUnknown: Boolean
|
||||
get() =
|
||||
this == null ||
|
||||
this.equals("unknown", true) ||
|
||||
this.equals("und", true) ||
|
||||
this.equals("undetermined", true) ||
|
||||
this.equals("mul", true) ||
|
||||
this.equals("zxx", true)
|
||||
|
|
|
|||
|
|
@ -104,7 +104,9 @@ class ThemeSongPlayer
|
|||
}
|
||||
|
||||
fun stop() {
|
||||
Timber.v("Stopping theme song")
|
||||
player.stop()
|
||||
if (player.isPlaying) {
|
||||
Timber.v("Stopping theme song")
|
||||
player.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,31 +22,53 @@ class TrailerService
|
|||
@param:ApplicationContext private val context: Context,
|
||||
private val api: ApiClient,
|
||||
) {
|
||||
suspend fun getTrailers(item: BaseItem): List<Trailer> {
|
||||
val remoteTrailers =
|
||||
item.data.remoteTrailers
|
||||
?.mapNotNull { t ->
|
||||
t.url?.let { url ->
|
||||
val name =
|
||||
t.name
|
||||
// TODO would be nice to clean up the trailer name
|
||||
fun getRemoteTrailers(item: BaseItem): List<Trailer> =
|
||||
item.data.remoteTrailers
|
||||
?.mapNotNull { t ->
|
||||
t.url?.let { url ->
|
||||
val name =
|
||||
t.name
|
||||
// TODO would be nice to clean up the trailer name
|
||||
// ?.replace(item.name ?: "", "")
|
||||
// ?.removePrefix(" - ")
|
||||
?: context.getString(R.string.trailer)
|
||||
RemoteTrailer(name, url)
|
||||
}
|
||||
}.orEmpty()
|
||||
.sortedBy { it.name }
|
||||
val localTrailerCount = item.data.localTrailerCount ?: 0
|
||||
?: context.getString(R.string.trailer)
|
||||
val subtitle =
|
||||
when (url.toUri().host) {
|
||||
"youtube.com", "www.youtube.com" -> "YouTube"
|
||||
else -> null
|
||||
}
|
||||
RemoteTrailer(name, url, subtitle)
|
||||
}
|
||||
}.orEmpty()
|
||||
.sortedWith(
|
||||
compareBy(
|
||||
{
|
||||
// Try to show official trailers first & teasers last
|
||||
when {
|
||||
it.name.contains("Official Trailer", true) -> 0
|
||||
it.name.contains("Official Theatrical Trailer", true) -> 0
|
||||
it.name.contains("Teaser", true) -> 10
|
||||
it.name.contains("Trailer", true) -> 1
|
||||
else -> 5
|
||||
}
|
||||
},
|
||||
{
|
||||
it.name
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
suspend fun getLocalTrailers(item: BaseItem): List<Trailer> {
|
||||
val localTrailerCount = item.data.localTrailerCount ?: return emptyList()
|
||||
val localTrailers =
|
||||
if (localTrailerCount > 0) {
|
||||
api.userLibraryApi.getLocalTrailers(item.id).content.map {
|
||||
LocalTrailer(BaseItem.Companion.from(it, api))
|
||||
LocalTrailer(BaseItem.from(it, api))
|
||||
}
|
||||
} else {
|
||||
listOf()
|
||||
}
|
||||
return localTrailers + remoteTrailers
|
||||
return localTrailers
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
@ -66,7 +88,6 @@ class TrailerService
|
|||
navigateTo.invoke(
|
||||
Destination.Playback(
|
||||
itemId = trailer.baseItem.id,
|
||||
item = trailer.baseItem,
|
||||
positionMs = 0L,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ import kotlinx.serialization.json.jsonPrimitive
|
|||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.internal.headersContentLength
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
|
|
@ -211,7 +210,7 @@ class UpdateChecker
|
|||
if (it.isSuccessful && it.body != null) {
|
||||
Timber.v("Request successful for ${release.downloadUrl}")
|
||||
withContext(Dispatchers.Main) {
|
||||
callback.contentLength(it.headersContentLength())
|
||||
callback.contentLength(it.body.contentLength())
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val contentValues =
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import com.github.damontecres.wholphin.data.ServerRepository
|
|||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
|
||||
import com.github.damontecres.wholphin.services.SeerrApi
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.RememberTabManager
|
||||
import dagger.Module
|
||||
|
|
@ -174,4 +175,10 @@ object AppModule {
|
|||
@Singleton
|
||||
@IoCoroutineScope
|
||||
fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun seerrApi(
|
||||
@StandardOkHttpClient okHttpClient: OkHttpClient,
|
||||
) = SeerrApi(okHttpClient)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ 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.SeerrServerDao
|
||||
import com.github.damontecres.wholphin.data.ServerPreferencesDao
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
|
||||
|
|
@ -61,6 +62,10 @@ object DatabaseModule {
|
|||
@Singleton
|
||||
fun playbackLanguageChoiceDao(db: AppDatabase): PlaybackLanguageChoiceDao = db.playbackLanguageChoiceDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun seerrServerDao(db: AppDatabase): SeerrServerDao = db.seerrServerDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun userPreferencesDataStore(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
package com.github.damontecres.wholphin.services.tvprovider
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.await
|
||||
import androidx.work.workDataOf
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import dagger.hilt.android.qualifiers.ActivityContext
|
||||
import dagger.hilt.android.scopes.ActivityScoped
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.toJavaDuration
|
||||
|
||||
@ActivityScoped
|
||||
class TvProviderSchedulerService
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ActivityContext private val context: Context,
|
||||
private val serverRepository: ServerRepository,
|
||||
) {
|
||||
private val activity = (context as AppCompatActivity)
|
||||
private val workManager = WorkManager.getInstance(context)
|
||||
|
||||
private val supportsTvProvider =
|
||||
// TODO <=25 has limited support
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
|
||||
context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
|
||||
|
||||
init {
|
||||
serverRepository.current.observe(activity) { user ->
|
||||
workManager.cancelUniqueWork(TvProviderWorker.WORK_NAME)
|
||||
if (supportsTvProvider) {
|
||||
if (user != null) {
|
||||
activity.lifecycleScope.launchIO(ExceptionHandler()) {
|
||||
Timber.i("Scheduling TvProviderWorker for ${user.user}")
|
||||
workManager
|
||||
.enqueueUniquePeriodicWork(
|
||||
uniqueWorkName = TvProviderWorker.WORK_NAME,
|
||||
existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE,
|
||||
request =
|
||||
PeriodicWorkRequestBuilder<TvProviderWorker>(
|
||||
repeatInterval = 1.hours.toJavaDuration(),
|
||||
).setBackoffCriteria(
|
||||
BackoffPolicy.LINEAR,
|
||||
15.minutes.toJavaDuration(),
|
||||
).setInputData(
|
||||
workDataOf(
|
||||
TvProviderWorker.PARAM_USER_ID to user.user.id.toString(),
|
||||
TvProviderWorker.PARAM_SERVER_ID to user.server.id.toString(),
|
||||
),
|
||||
).build(),
|
||||
).await()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun launchOneTimeRefresh() {
|
||||
if (supportsTvProvider) {
|
||||
activity.lifecycleScope.launchIO(ExceptionHandler()) {
|
||||
serverRepository.current.value?.let { user ->
|
||||
Timber.i("Scheduling on-time TvProviderWorker for ${user.user}")
|
||||
workManager.enqueue(
|
||||
OneTimeWorkRequestBuilder<TvProviderWorker>()
|
||||
.setInputData(
|
||||
workDataOf(
|
||||
TvProviderWorker.PARAM_USER_ID to user.user.id.toString(),
|
||||
TvProviderWorker.PARAM_SERVER_ID to user.server.id.toString(),
|
||||
),
|
||||
).build(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,409 @@
|
|||
package com.github.damontecres.wholphin.services.tvprovider
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.net.toUri
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.tvprovider.media.tv.Channel
|
||||
import androidx.tvprovider.media.tv.PreviewProgram
|
||||
import androidx.tvprovider.media.tv.TvContractCompat
|
||||
import androidx.tvprovider.media.tv.TvContractCompat.Channels
|
||||
import androidx.tvprovider.media.tv.TvContractCompat.WatchNextPrograms
|
||||
import androidx.tvprovider.media.tv.WatchNextProgram
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.github.damontecres.wholphin.MainActivity
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||
import com.github.damontecres.wholphin.services.LatestNextUpService
|
||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import timber.log.Timber
|
||||
import java.time.ZoneId
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
@HiltWorker
|
||||
class TvProviderWorker
|
||||
@AssistedInject
|
||||
constructor(
|
||||
@Assisted private val context: Context,
|
||||
@Assisted workerParams: WorkerParameters,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val preferences: DataStore<AppPreferences>,
|
||||
private val api: ApiClient,
|
||||
private val latestNextUpService: LatestNextUpService,
|
||||
private val imageUrlService: ImageUrlService,
|
||||
) : CoroutineWorker(context, workerParams) {
|
||||
override suspend fun doWork(): Result {
|
||||
Timber.d("Start")
|
||||
val serverId =
|
||||
inputData.getString(PARAM_SERVER_ID)?.toUUIDOrNull() ?: return Result.failure()
|
||||
val userId =
|
||||
inputData.getString(PARAM_USER_ID)?.toUUIDOrNull() ?: return Result.failure()
|
||||
|
||||
if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) {
|
||||
// Not active
|
||||
var currentUser = serverRepository.current.value
|
||||
if (currentUser == null) {
|
||||
serverRepository.restoreSession(serverId, userId)
|
||||
currentUser = serverRepository.current.value
|
||||
}
|
||||
if (currentUser == null) {
|
||||
Timber.w("No user found during run")
|
||||
return Result.failure()
|
||||
}
|
||||
}
|
||||
try {
|
||||
val prefs = preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
|
||||
val potentialItemsToAdd =
|
||||
getPotentialItems(
|
||||
userId,
|
||||
prefs.homePagePreferences.enableRewatchingNextUp,
|
||||
prefs.homePagePreferences.combineContinueNext,
|
||||
)
|
||||
val potentialItemsToAddIds = potentialItemsToAdd.map { it.id.toString() }
|
||||
|
||||
Timber.v("potentialItemsToAddIds=%s", potentialItemsToAddIds)
|
||||
val currentItems = getCurrentTvChannelNextUp()
|
||||
val currentItemIds = currentItems.map { it.internalProviderId }
|
||||
|
||||
// TODO Remove after v0.3.10 release
|
||||
// This cleans up duplicates added to the watch next due a bug in https://github.com/damontecres/Wholphin/pull/372
|
||||
currentItems.groupBy { it.internalProviderId }.forEach { (id, items) ->
|
||||
if (items.size > 1) {
|
||||
Timber.v("Duplicate ID %s", id)
|
||||
items
|
||||
.subList(1, items.size)
|
||||
.map { TvContractCompat.buildWatchNextProgramUri(it.id) }
|
||||
.forEach {
|
||||
context.contentResolver.delete(it, null, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
// End temporary clean up
|
||||
|
||||
val toRemove =
|
||||
currentItems.filterNot { it.internalProviderId in potentialItemsToAddIds }
|
||||
|
||||
val userRemoved = currentItems.filterNot { it.isBrowsable }
|
||||
val userRemovedIds = userRemoved.map { it.internalProviderId }
|
||||
Timber.v("toRemove (%s)=%s", toRemove.size, toRemove.map { it.internalProviderId })
|
||||
val toAdd =
|
||||
potentialItemsToAdd.filter { it.id.toString() !in currentItemIds && it.id.toString() !in userRemovedIds }
|
||||
Timber.v("toAdd (%s)=%s", toAdd.size, toAdd.map { it.id })
|
||||
|
||||
// Remove existing items if they are no longer in the next up from server
|
||||
(toRemove + userRemoved)
|
||||
.map { TvContractCompat.buildWatchNextProgramUri(it.id) }
|
||||
.forEach {
|
||||
context.contentResolver.delete(it, null, null)
|
||||
}
|
||||
|
||||
// Add new ones
|
||||
val addedCount =
|
||||
context.contentResolver.bulkInsert(
|
||||
WatchNextPrograms.CONTENT_URI,
|
||||
toAdd
|
||||
.map { convert(it).toContentValues() }
|
||||
.toTypedArray(),
|
||||
)
|
||||
Timber.v("Added %s", addedCount)
|
||||
|
||||
addOtherChannels()
|
||||
|
||||
Timber.d("Completed successfully")
|
||||
} catch (_: ApiClientException) {
|
||||
return Result.retry()
|
||||
} catch (_: Exception) {
|
||||
return Result.failure()
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
private suspend fun getPotentialItems(
|
||||
userId: UUID,
|
||||
enableRewatching: Boolean,
|
||||
combineContinueNext: Boolean,
|
||||
): List<BaseItem> {
|
||||
val resumeItems = latestNextUpService.getResume(userId, 10, true)
|
||||
val seriesIds = resumeItems.mapNotNull { it.data.seriesId }
|
||||
val nextUpItems =
|
||||
latestNextUpService
|
||||
.getNextUp(userId, 10, enableRewatching, false)
|
||||
.filter { it.data.seriesId != null && it.data.seriesId !in seriesIds }
|
||||
return if (combineContinueNext) {
|
||||
latestNextUpService.buildCombined(resumeItems, nextUpItems)
|
||||
} else {
|
||||
resumeItems + nextUpItems
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getCurrentTvChannelNextUp(): List<WatchNextProgram> =
|
||||
context.contentResolver
|
||||
.query(
|
||||
WatchNextPrograms.CONTENT_URI,
|
||||
WatchNextProgram.PROJECTION,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
)?.map(WatchNextProgram::fromCursor)
|
||||
.orEmpty()
|
||||
|
||||
private fun convert(item: BaseItem): WatchNextProgram =
|
||||
WatchNextProgram
|
||||
.Builder()
|
||||
.apply {
|
||||
val dto = item.data
|
||||
setInternalProviderId(item.id.toString())
|
||||
|
||||
val type =
|
||||
when (item.type) {
|
||||
BaseItemKind.EPISODE -> WatchNextPrograms.TYPE_TV_EPISODE
|
||||
BaseItemKind.MOVIE -> WatchNextPrograms.TYPE_MOVIE
|
||||
else -> WatchNextPrograms.TYPE_CLIP
|
||||
}
|
||||
setType(type)
|
||||
|
||||
val resumePosition = dto.userData?.playbackPositionTicks?.ticks
|
||||
if (resumePosition != null && resumePosition >= 2.minutes) {
|
||||
// https://developer.android.com/training/tv/discovery/guidelines-app-developers#types-of-content
|
||||
setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE)
|
||||
setLastPlaybackPositionMillis(resumePosition.inWholeMilliseconds.toInt())
|
||||
} else {
|
||||
setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEXT)
|
||||
}
|
||||
dto.runTimeTicks
|
||||
?.ticks
|
||||
?.inWholeMilliseconds
|
||||
?.toInt()
|
||||
?.let(::setDurationMillis)
|
||||
|
||||
setLastEngagementTimeUtcMillis(
|
||||
dto.userData
|
||||
?.lastPlayedDate
|
||||
?.atZone(ZoneId.systemDefault())
|
||||
?.toEpochSecond()
|
||||
?: Date().time, // TODO
|
||||
)
|
||||
|
||||
setTitle(item.title)
|
||||
setDescription(dto.overview)
|
||||
if (item.type == BaseItemKind.EPISODE) {
|
||||
setEpisodeTitle(item.name)
|
||||
dto.indexNumber?.let(::setEpisodeNumber)
|
||||
dto.parentIndexNumber?.let(::setSeasonNumber)
|
||||
}
|
||||
|
||||
setPosterArtAspectRatio(TvContractCompat.PreviewProgramColumns.ASPECT_RATIO_16_9)
|
||||
val imageType =
|
||||
when (item.type) {
|
||||
BaseItemKind.EPISODE -> ImageType.THUMB
|
||||
else -> ImageType.PRIMARY
|
||||
}
|
||||
setPosterArtUri(imageUrlService.getItemImageUrl(item, imageType)!!.toUri())
|
||||
|
||||
setIntent(
|
||||
Intent(context, MainActivity::class.java)
|
||||
.putExtra(MainActivity.INTENT_ITEM_ID, item.id.toString())
|
||||
.putExtra(MainActivity.INTENT_ITEM_TYPE, item.type.serialName)
|
||||
.apply {
|
||||
if (item.type == BaseItemKind.EPISODE) {
|
||||
putExtra(
|
||||
MainActivity.INTENT_SERIES_ID,
|
||||
dto.seriesId?.toString(),
|
||||
)
|
||||
putExtra(
|
||||
MainActivity.INTENT_SEASON_ID,
|
||||
dto.seasonId?.toString(),
|
||||
)
|
||||
dto.parentIndexNumber?.let {
|
||||
putExtra(
|
||||
MainActivity.INTENT_SEASON_NUMBER,
|
||||
it,
|
||||
)
|
||||
}
|
||||
dto.indexNumber?.let {
|
||||
putExtra(
|
||||
MainActivity.INTENT_EPISODE_NUMBER,
|
||||
it,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}.build()
|
||||
|
||||
private suspend fun addOtherChannels() {
|
||||
val preferences = preferences.data.firstOrNull()
|
||||
val channelsPrefs = context.getSharedPreferences("channels", Context.MODE_PRIVATE)
|
||||
|
||||
val latest =
|
||||
api.userLibraryApi
|
||||
.getLatestMedia(
|
||||
GetLatestMediaRequest(
|
||||
fields = SlimItemFields,
|
||||
imageTypeLimit = 1,
|
||||
parentId = null,
|
||||
groupItems = true,
|
||||
limit =
|
||||
preferences?.homePagePreferences?.maxItemsPerRow
|
||||
?: AppPreference.HomePageItems.defaultValue.toInt(),
|
||||
isPlayed = null, // Server will handle user's preference
|
||||
),
|
||||
).content
|
||||
.map { BaseItem(it, true) }
|
||||
|
||||
var channelId = channelsPrefs.getString("latest", null)?.toUri()
|
||||
if (channelId == null) {
|
||||
Timber.d("channelId for latest is null")
|
||||
val channel =
|
||||
Channel
|
||||
.Builder()
|
||||
.apply {
|
||||
setDisplayName(context.getString(R.string.recently_added))
|
||||
setType(Channels.TYPE_PREVIEW)
|
||||
setAppLinkIntent(Intent(context, MainActivity::class.java))
|
||||
}.build()
|
||||
channelId =
|
||||
context.contentResolver.insert(
|
||||
Channels.CONTENT_URI,
|
||||
channel.toContentValues(),
|
||||
)
|
||||
if (channelId != null) {
|
||||
channelsPrefs.edit(true) {
|
||||
putString("latest", channelId.toString())
|
||||
}
|
||||
TvContractCompat.requestChannelBrowsable(
|
||||
context,
|
||||
ContentUris.parseId(channelId),
|
||||
)
|
||||
} else {
|
||||
Timber.w("channelId was null")
|
||||
throw IllegalStateException("channelId was null")
|
||||
}
|
||||
}
|
||||
val programs = latest.map { convert(channelId, it).toContentValues() }.toTypedArray()
|
||||
|
||||
// Delete & replace
|
||||
context.contentResolver.delete(TvContractCompat.PreviewPrograms.CONTENT_URI, null, null)
|
||||
val count =
|
||||
context.contentResolver.bulkInsert(
|
||||
TvContractCompat.PreviewPrograms.CONTENT_URI,
|
||||
programs,
|
||||
)
|
||||
Timber.v("Inserted $count records")
|
||||
}
|
||||
|
||||
private fun convert(
|
||||
channelId: Uri,
|
||||
item: BaseItem,
|
||||
): PreviewProgram =
|
||||
PreviewProgram
|
||||
.Builder()
|
||||
.apply {
|
||||
setChannelId(ContentUris.parseId(channelId))
|
||||
|
||||
val dto = item.data
|
||||
setInternalProviderId(item.id.toString())
|
||||
|
||||
val type =
|
||||
when (item.type) {
|
||||
BaseItemKind.SERIES -> WatchNextPrograms.TYPE_TV_SERIES
|
||||
BaseItemKind.SEASON -> WatchNextPrograms.TYPE_TV_SEASON
|
||||
BaseItemKind.EPISODE -> WatchNextPrograms.TYPE_TV_EPISODE
|
||||
BaseItemKind.MOVIE -> WatchNextPrograms.TYPE_MOVIE
|
||||
else -> WatchNextPrograms.TYPE_CLIP
|
||||
}
|
||||
setType(type)
|
||||
|
||||
val resumePosition = dto.userData?.playbackPositionTicks?.ticks
|
||||
if (resumePosition != null && resumePosition >= 2.minutes) {
|
||||
// https://developer.android.com/training/tv/discovery/guidelines-app-developers#types-of-content
|
||||
setLastPlaybackPositionMillis(resumePosition.inWholeMilliseconds.toInt())
|
||||
}
|
||||
dto.runTimeTicks
|
||||
?.ticks
|
||||
?.inWholeMilliseconds
|
||||
?.toInt()
|
||||
?.let(::setDurationMillis)
|
||||
|
||||
setTitle(item.title)
|
||||
setDescription(dto.overview)
|
||||
if (item.type == BaseItemKind.EPISODE) {
|
||||
setEpisodeTitle(item.name)
|
||||
dto.indexNumber?.let(::setEpisodeNumber)
|
||||
dto.parentIndexNumber?.let(::setSeasonNumber)
|
||||
}
|
||||
|
||||
setPosterArtAspectRatio(TvContractCompat.PreviewProgramColumns.ASPECT_RATIO_16_9)
|
||||
setPosterArtUri(imageUrlService.getItemImageUrl(item, ImageType.THUMB)!!.toUri())
|
||||
|
||||
setIntent(
|
||||
Intent(context, MainActivity::class.java)
|
||||
.putExtra(MainActivity.INTENT_ITEM_ID, item.id.toString())
|
||||
.putExtra(MainActivity.INTENT_ITEM_TYPE, item.type.serialName)
|
||||
.apply {
|
||||
if (item.type == BaseItemKind.EPISODE) {
|
||||
putExtra(
|
||||
MainActivity.INTENT_SERIES_ID,
|
||||
dto.seriesId?.toString(),
|
||||
)
|
||||
putExtra(
|
||||
MainActivity.INTENT_SEASON_ID,
|
||||
dto.seasonId?.toString(),
|
||||
)
|
||||
dto.parentIndexNumber?.let {
|
||||
putExtra(
|
||||
MainActivity.INTENT_SEASON_NUMBER,
|
||||
it,
|
||||
)
|
||||
}
|
||||
dto.indexNumber?.let {
|
||||
putExtra(
|
||||
MainActivity.INTENT_EPISODE_NUMBER,
|
||||
it,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}.build()
|
||||
|
||||
companion object {
|
||||
const val WORK_NAME = "com.github.damontecres.wholphin.services.tvprovider.TvProviderWorker"
|
||||
const val PARAM_USER_ID = "userId"
|
||||
const val PARAM_SERVER_ID = "serverId"
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> Cursor.map(transform: (Cursor) -> T): List<T> =
|
||||
this.use {
|
||||
buildList {
|
||||
if (moveToFirst()) {
|
||||
do {
|
||||
add(transform.invoke(this@map))
|
||||
} while (moveToNext())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -78,12 +78,13 @@ inline fun <T> List<T>.indexOfFirstOrNull(predicate: (T) -> Boolean): Int? {
|
|||
/**
|
||||
* Try to call [FocusRequester.requestFocus], but catch & log the exception if something is not configured properly
|
||||
*/
|
||||
fun FocusRequester.tryRequestFocus(): Boolean =
|
||||
fun FocusRequester.tryRequestFocus(tag: String? = null): Boolean =
|
||||
try {
|
||||
requestFocus()
|
||||
tag?.let { Timber.v("Request focus tag=%s", tag) }
|
||||
true
|
||||
} catch (ex: IllegalStateException) {
|
||||
Timber.w(ex, "Failed to request focus")
|
||||
Timber.w(ex, "Failed to request focus, tag=%s", tag)
|
||||
false
|
||||
}
|
||||
|
||||
|
|
@ -167,6 +168,26 @@ fun OneTimeLaunchedEffect(runOnceBlock: suspend CoroutineScope.() -> Unit) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls [tryRequestFocus] on the provided [FocusRequester] when this composable launches or resumes
|
||||
*/
|
||||
@Composable
|
||||
fun RequestOrRestoreFocus(
|
||||
focusRequester: FocusRequester?,
|
||||
debugKey: String? = null,
|
||||
) {
|
||||
if (focusRequester != null) {
|
||||
LaunchedEffect(Unit) {
|
||||
debugKey?.let { Timber.v("RequestOrRestoreFocus: %s", it) }
|
||||
focusRequester.tryRequestFocus()
|
||||
}
|
||||
// LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
|
||||
// debugKey?.let { Timber.v("RequestOrRestoreFocus onResume: %s", it) }
|
||||
// focusRequester.tryRequestFocus()
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
fun Modifier.enableMarquee(focused: Boolean) =
|
||||
if (focused) {
|
||||
basicMarquee(
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
package com.github.damontecres.wholphin.ui
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import com.github.damontecres.wholphin.R
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.MediaSegmentType
|
||||
import timber.log.Timber
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.DateTimeParseException
|
||||
import java.time.format.FormatStyle
|
||||
import java.util.Locale
|
||||
|
||||
|
|
@ -23,6 +28,16 @@ fun formatDateTime(dateTime: LocalDateTime): String = DateFormatter.format(dateT
|
|||
|
||||
fun formatDate(dateTime: LocalDate): String = DateFormatter.format(dateTime)
|
||||
|
||||
fun toLocalDate(date: String?): LocalDate? =
|
||||
date?.let {
|
||||
try {
|
||||
LocalDate.parse(it)
|
||||
} catch (_: DateTimeParseException) {
|
||||
Timber.w("Could not parse date: %s", date)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If the item has season & episode info, format as `S# E#`
|
||||
*/
|
||||
|
|
@ -140,3 +155,31 @@ val MediaSegmentType.skipStringRes: Int
|
|||
MediaSegmentType.OUTRO -> R.string.skip_segment_outro
|
||||
MediaSegmentType.INTRO -> R.string.skip_segment_intro
|
||||
}
|
||||
|
||||
fun AnnotatedString.Builder.dot() = append(" \u2022 ")
|
||||
|
||||
fun listToDotString(
|
||||
strings: List<String>,
|
||||
communityRating: Float?,
|
||||
criticRating: Float?,
|
||||
): AnnotatedString =
|
||||
buildAnnotatedString {
|
||||
strings.forEachIndexed { index, string ->
|
||||
append(string)
|
||||
if (index != strings.lastIndex) dot()
|
||||
communityRating?.let {
|
||||
dot()
|
||||
append(String.format(Locale.getDefault(), "%.1f", it))
|
||||
appendInlineContent(id = "star")
|
||||
}
|
||||
criticRating?.let {
|
||||
dot()
|
||||
append("${it.toInt()}%")
|
||||
if (it >= 60f) {
|
||||
appendInlineContent(id = "fresh")
|
||||
} else {
|
||||
appendInlineContent(id = "rotten")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,18 +21,23 @@ val LocalImageUrlService =
|
|||
/**
|
||||
* Colors not associated with the theme
|
||||
*/
|
||||
sealed class AppColors private constructor() {
|
||||
companion object {
|
||||
val TransparentBlack25 = Color(0x40000000)
|
||||
val TransparentBlack50 = Color(0x80000000)
|
||||
val TransparentBlack75 = Color(0xBF000000)
|
||||
object AppColors {
|
||||
val TransparentBlack25 = Color(0x40000000)
|
||||
val TransparentBlack50 = Color(0x80000000)
|
||||
val TransparentBlack75 = Color(0xBF000000)
|
||||
|
||||
val DarkGreen = Color(0xFF114000)
|
||||
val DarkRed = Color(0xFF400000)
|
||||
val DarkCyan = Color(0xFF21556E)
|
||||
val DarkPurple = Color(0xFF261370)
|
||||
val DarkGreen = Color(0xFF114000)
|
||||
val DarkRed = Color(0xFF400000)
|
||||
val DarkCyan = Color(0xFF21556E)
|
||||
val DarkPurple = Color(0xFF261370)
|
||||
|
||||
val GoldenYellow = Color(0xFFDAB440)
|
||||
val GoldenYellow = Color(0xFFDAB440)
|
||||
|
||||
object Discover {
|
||||
val Blue = Color(37, 99, 235)
|
||||
val Purple = Color(147, 51, 234)
|
||||
val Green = Color(74, 222, 128)
|
||||
val Yellow = Color(234, 179, 8)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -51,6 +56,7 @@ val DefaultItemFields =
|
|||
ItemFields.SORT_NAME,
|
||||
ItemFields.CHAPTERS,
|
||||
ItemFields.MEDIA_SOURCES,
|
||||
ItemFields.MEDIA_SOURCE_COUNT,
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
@ -63,10 +69,12 @@ val SlimItemFields =
|
|||
ItemFields.CHILD_COUNT,
|
||||
ItemFields.OVERVIEW,
|
||||
ItemFields.SORT_NAME,
|
||||
ItemFields.MEDIA_SOURCE_COUNT,
|
||||
)
|
||||
|
||||
object Cards {
|
||||
val height2x3 = 172.dp
|
||||
val heightEpisode = height2x3 * .75f
|
||||
val playedPercentHeight = 6.dp
|
||||
val serverUserCircle = height2x3 * .75f
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,294 @@
|
|||
package com.github.damontecres.wholphin.ui.cards
|
||||
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
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.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
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.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Card
|
||||
import androidx.tv.material3.CardDefaults
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||
import com.github.damontecres.wholphin.data.model.SeerrAvailability
|
||||
import com.github.damontecres.wholphin.data.model.SeerrItemType
|
||||
import com.github.damontecres.wholphin.ui.AppColors
|
||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||
import com.github.damontecres.wholphin.ui.Cards
|
||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||
import com.github.damontecres.wholphin.ui.enableMarquee
|
||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
@NonRestartableComposable
|
||||
fun DiscoverItemCard(
|
||||
item: DiscoverItem?,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
showOverlay: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
|
||||
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
|
||||
var focusedAfterDelay by remember { mutableStateOf(false) }
|
||||
|
||||
val hideOverlayDelay = 500L
|
||||
if (focused) {
|
||||
LaunchedEffect(Unit) {
|
||||
delay(hideOverlayDelay)
|
||||
if (focused) {
|
||||
focusedAfterDelay = true
|
||||
} else {
|
||||
focusedAfterDelay = false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
focusedAfterDelay = false
|
||||
}
|
||||
val width = Cards.height2x3 * AspectRatios.TALL
|
||||
val height = Dp.Unspecified * (1f / AspectRatios.TALL)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(spaceBetween),
|
||||
modifier = modifier.size(width, height),
|
||||
) {
|
||||
Card(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(Dp.Unspecified, Cards.height2x3)
|
||||
.aspectRatio(AspectRatios.TALL),
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
interactionSource = interactionSource,
|
||||
colors =
|
||||
CardDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
ItemCardImage(
|
||||
imageUrl = item?.posterUrl,
|
||||
name = item?.title,
|
||||
showOverlay = false,
|
||||
favorite = false,
|
||||
watched = false,
|
||||
unwatchedCount = 0,
|
||||
watchedPercent = null,
|
||||
numberOfVersions = -1,
|
||||
useFallbackText = false,
|
||||
contentScale = ContentScale.FillBounds,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize(),
|
||||
)
|
||||
when (item?.availability) {
|
||||
SeerrAvailability.PENDING,
|
||||
SeerrAvailability.PROCESSING,
|
||||
-> {
|
||||
PendingIndicator(Modifier.align(Alignment.TopEnd))
|
||||
}
|
||||
|
||||
SeerrAvailability.PARTIALLY_AVAILABLE -> {
|
||||
PartiallyAvailableIndicator(Modifier.align(Alignment.TopEnd))
|
||||
}
|
||||
|
||||
SeerrAvailability.AVAILABLE,
|
||||
-> {
|
||||
AvailableIndicator(Modifier.align(Alignment.TopEnd))
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
if (showOverlay) {
|
||||
val color =
|
||||
remember(item?.type) {
|
||||
when (item?.type) {
|
||||
SeerrItemType.MOVIE -> AppColors.Discover.Blue
|
||||
SeerrItemType.TV -> AppColors.Discover.Purple
|
||||
SeerrItemType.PERSON -> AppColors.Discover.Green
|
||||
SeerrItemType.UNKNOWN -> Color.Black
|
||||
null -> Color.Black
|
||||
}.copy(alpha = .8f)
|
||||
}
|
||||
val text =
|
||||
remember(item?.type) {
|
||||
when (item?.type) {
|
||||
SeerrItemType.MOVIE -> R.plurals.movies
|
||||
SeerrItemType.TV -> R.plurals.tv_shows
|
||||
SeerrItemType.PERSON -> R.plurals.people
|
||||
SeerrItemType.UNKNOWN -> null
|
||||
null -> null
|
||||
}
|
||||
}
|
||||
text?.let {
|
||||
Text(
|
||||
text = pluralStringResource(it, 1),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontSize = 10.sp,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(4.dp)
|
||||
.background(
|
||||
color = color,
|
||||
shape = CircleShape,
|
||||
).padding(4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(bottom = spaceBelow)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = item?.title ?: "",
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp)
|
||||
.enableMarquee(focusedAfterDelay),
|
||||
)
|
||||
Text(
|
||||
text = item?.releaseDate?.year?.toString() ?: "",
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp)
|
||||
.enableMarquee(focusedAfterDelay),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PendingIndicator(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.padding(4.dp)
|
||||
.border(
|
||||
width = .5.dp,
|
||||
color = AppColors.Discover.Yellow,
|
||||
shape = CircleShape,
|
||||
).background(
|
||||
color = Color.White.copy(alpha = .85f),
|
||||
shape = CircleShape,
|
||||
).size(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.fa_bell),
|
||||
fontFamily = FontAwesome,
|
||||
fontSize = 10.sp,
|
||||
color = AppColors.Discover.Yellow,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AvailableIndicator(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.padding(4.dp)
|
||||
.border(
|
||||
width = .5.dp,
|
||||
color = Color.White,
|
||||
shape = CircleShape,
|
||||
).background(
|
||||
color = AppColors.Discover.Green.copy(alpha = .85f),
|
||||
shape = CircleShape,
|
||||
).size(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.fa_check),
|
||||
fontFamily = FontAwesome,
|
||||
fontSize = 10.sp,
|
||||
color = Color.White,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PartiallyAvailableIndicator(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.padding(4.dp)
|
||||
.border(
|
||||
width = .5.dp,
|
||||
color = Color.White,
|
||||
shape = CircleShape,
|
||||
).background(
|
||||
color = AppColors.Discover.Green.copy(alpha = .85f),
|
||||
shape = CircleShape,
|
||||
).size(16.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(width = 10.dp, height = 2.dp)
|
||||
.background(
|
||||
color = Color.White,
|
||||
shape = CircleShape,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewTvSpec
|
||||
@Composable
|
||||
private fun Preview() {
|
||||
WholphinTheme {
|
||||
Column {
|
||||
PendingIndicator()
|
||||
AvailableIndicator()
|
||||
PartiallyAvailableIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -97,6 +97,7 @@ fun EpisodeCard(
|
|||
watched = dto?.userData?.played ?: false,
|
||||
unwatchedCount = dto?.userData?.unplayedItemCount ?: -1,
|
||||
watchedPercent = dto?.userData?.playedPercentage,
|
||||
numberOfVersions = dto?.mediaSourceCount ?: 0,
|
||||
useFallbackText = false,
|
||||
modifier =
|
||||
Modifier
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ fun ExtrasRow(
|
|||
isPlayed = false,
|
||||
unplayedItemCount = -1,
|
||||
playedPercentage = -1.0,
|
||||
numberOfVersions = -1,
|
||||
aspectRatio = AspectRatios.FOUR_THREE, // TODO
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ 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
|
||||
|
|
@ -34,7 +33,6 @@ 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
|
||||
|
|
@ -77,7 +75,7 @@ fun GenreCard(
|
|||
contentDescription = null,
|
||||
modifier =
|
||||
Modifier
|
||||
.alpha(.6f)
|
||||
.alpha(.75f)
|
||||
.aspectRatio(AspectRatios.WIDE)
|
||||
.fillMaxSize(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ fun GridCard(
|
|||
watched = dto?.userData?.played ?: false,
|
||||
unwatchedCount = dto?.userData?.unplayedItemCount ?: -1,
|
||||
watchedPercent = dto?.userData?.playedPercentage,
|
||||
numberOfVersions = dto?.mediaSourceCount ?: 0,
|
||||
useFallbackText = false,
|
||||
contentScale = imageContentScale,
|
||||
modifier =
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ fun ItemCardImage(
|
|||
watched: Boolean,
|
||||
unwatchedCount: Int,
|
||||
watchedPercent: Double?,
|
||||
numberOfVersions: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
imageType: ImageType = ImageType.PRIMARY,
|
||||
useFallbackText: Boolean = true,
|
||||
|
|
@ -86,6 +87,7 @@ fun ItemCardImage(
|
|||
watched = watched,
|
||||
unwatchedCount = unwatchedCount,
|
||||
watchedPercent = watchedPercent,
|
||||
numberOfVersions = numberOfVersions,
|
||||
modifier =
|
||||
modifier.onLayoutRectChanged(
|
||||
throttleMillis = 100,
|
||||
|
|
@ -107,9 +109,17 @@ fun ItemCardImage(
|
|||
watched: Boolean,
|
||||
unwatchedCount: Int,
|
||||
watchedPercent: Double?,
|
||||
numberOfVersions: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
useFallbackText: Boolean = true,
|
||||
contentScale: ContentScale = ContentScale.Fit,
|
||||
fallback: @Composable BoxScope.() -> Unit = {
|
||||
ItemCardImageFallback(
|
||||
name = name,
|
||||
useFallbackText = useFallbackText,
|
||||
modifier = Modifier,
|
||||
)
|
||||
},
|
||||
) {
|
||||
var imageError by remember(imageUrl) { mutableStateOf(false) }
|
||||
Box(
|
||||
|
|
@ -131,11 +141,7 @@ fun ItemCardImage(
|
|||
.align(Alignment.TopCenter),
|
||||
)
|
||||
} else {
|
||||
ItemCardImageFallback(
|
||||
name = name,
|
||||
useFallbackText = useFallbackText,
|
||||
modifier = Modifier,
|
||||
)
|
||||
fallback.invoke(this)
|
||||
}
|
||||
if (showOverlay) {
|
||||
ItemCardImageOverlay(
|
||||
|
|
@ -143,6 +149,7 @@ fun ItemCardImage(
|
|||
watched = watched,
|
||||
unwatchedCount = unwatchedCount,
|
||||
watchedPercent = watchedPercent,
|
||||
numberOfVersions = numberOfVersions,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
|
|
@ -198,20 +205,45 @@ fun ItemCardImageOverlay(
|
|||
watched: Boolean,
|
||||
unwatchedCount: Int,
|
||||
watchedPercent: Double?,
|
||||
numberOfVersions: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
if (favorite) {
|
||||
Text(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(8.dp),
|
||||
color = colorResource(android.R.color.holo_red_light),
|
||||
text = stringResource(R.string.fa_heart),
|
||||
fontSize = 20.sp,
|
||||
fontFamily = FontAwesome,
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(4.dp),
|
||||
) {
|
||||
if (numberOfVersions > 1) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.background(
|
||||
AppColors.TransparentBlack50,
|
||||
shape = RoundedCornerShape(25),
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = numberOfVersions.toString(),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
// fontSize = 16.sp,
|
||||
modifier = Modifier.padding(4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (favorite) {
|
||||
Text(
|
||||
color = colorResource(android.R.color.holo_red_light),
|
||||
text = stringResource(R.string.fa_heart),
|
||||
fontSize = 20.sp,
|
||||
fontFamily = FontAwesome,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.github.damontecres.wholphin.ui.cards
|
||||
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
|
|
@ -9,21 +10,19 @@ import androidx.compose.foundation.lazy.itemsIndexed
|
|||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
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.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||
import com.github.damontecres.wholphin.util.FocusPair
|
||||
import com.github.damontecres.wholphin.ui.rememberInt
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
|
||||
@Composable
|
||||
fun <T> ItemRow(
|
||||
|
|
@ -39,16 +38,20 @@ fun <T> ItemRow(
|
|||
onLongClick: () -> Unit,
|
||||
) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
focusPair: FocusPair? = null,
|
||||
cardOnFocus: ((isFocused: Boolean, index: Int) -> Unit)? = null,
|
||||
horizontalPadding: Dp = 16.dp,
|
||||
) {
|
||||
val state = rememberLazyListState()
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
var focusedIndex by remember { mutableIntStateOf(focusPair?.column ?: 0) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
var position by rememberInt()
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier,
|
||||
modifier =
|
||||
modifier.focusProperties {
|
||||
onEnter = {
|
||||
focusRequester.tryRequestFocus()
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
|
|
@ -62,67 +65,31 @@ fun <T> ItemRow(
|
|||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRestorer(focusPair?.focusRequester ?: firstFocus),
|
||||
.focusGroup()
|
||||
.focusRestorer(firstFocus)
|
||||
.focusRequester(focusRequester),
|
||||
) {
|
||||
itemsIndexed(items) { index, item ->
|
||||
val cardModifier =
|
||||
if (index == 0 && focusPair == null) {
|
||||
if (index == position) {
|
||||
Modifier.focusRequester(firstFocus)
|
||||
} else {
|
||||
if (focusPair != null && focusPair.column == index) {
|
||||
Modifier.focusRequester(focusPair.focusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
}.onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
focusedIndex = index
|
||||
}
|
||||
cardOnFocus?.invoke(it.isFocused, index)
|
||||
Modifier
|
||||
}
|
||||
cardContent.invoke(
|
||||
index,
|
||||
item,
|
||||
cardModifier,
|
||||
{ if (item != null) onClickItem.invoke(index, item) },
|
||||
{ if (item != null) onLongClickItem.invoke(index, item) },
|
||||
{
|
||||
position = index
|
||||
if (item != null) onClickItem.invoke(index, item)
|
||||
},
|
||||
{
|
||||
position = index
|
||||
if (item != null) onLongClickItem.invoke(index, item)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BannerItemRow(
|
||||
title: String,
|
||||
items: List<BaseItem?>,
|
||||
onClickItem: (Int, BaseItem) -> Unit,
|
||||
onLongClickItem: (Int, BaseItem) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
focusPair: FocusPair? = null,
|
||||
cardOnFocus: ((isFocused: Boolean, index: Int) -> Unit)? = null,
|
||||
aspectRatioOverride: Float? = null,
|
||||
) = ItemRow(
|
||||
title = title,
|
||||
items = items,
|
||||
onClickItem = onClickItem,
|
||||
onLongClickItem = onLongClickItem,
|
||||
modifier = modifier,
|
||||
cardContent = { index, item, modifier, onClick, onLongClick ->
|
||||
BannerCard(
|
||||
name = title,
|
||||
item = item,
|
||||
aspectRatio =
|
||||
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,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = modifier,
|
||||
interactionSource = null,
|
||||
)
|
||||
},
|
||||
focusPair = focusPair,
|
||||
cardOnFocus = cardOnFocus,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,37 +1,77 @@
|
|||
package com.github.damontecres.wholphin.ui.cards
|
||||
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Border
|
||||
import androidx.tv.material3.Card
|
||||
import androidx.tv.material3.CardDefaults
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.Person
|
||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||
import com.github.damontecres.wholphin.ui.enableMarquee
|
||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
import kotlinx.coroutines.delay
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import org.jellyfin.sdk.model.api.PersonKind
|
||||
|
||||
/**
|
||||
* A Card for a [Person] such as an actor or director
|
||||
*/
|
||||
@Composable
|
||||
fun PersonCard(
|
||||
item: Person,
|
||||
person: Person,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) = PersonCard(
|
||||
name = person.name,
|
||||
role = person.role,
|
||||
imageUrl = person.imageUrl,
|
||||
favorite = person.favorite,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = modifier,
|
||||
interactionSource = interactionSource,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun PersonCard(
|
||||
name: String?,
|
||||
role: String?,
|
||||
imageUrl: String?,
|
||||
favorite: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
@ -65,24 +105,62 @@ fun PersonCard(
|
|||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
interactionSource = interactionSource,
|
||||
shape = CardDefaults.shape(CircleShape),
|
||||
border =
|
||||
CardDefaults.border(
|
||||
focusedBorder =
|
||||
Border(
|
||||
border =
|
||||
BorderStroke(
|
||||
width = 3.dp,
|
||||
color = MaterialTheme.colorScheme.border,
|
||||
),
|
||||
shape = CircleShape,
|
||||
),
|
||||
),
|
||||
colors =
|
||||
CardDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
) {
|
||||
ItemCardImage(
|
||||
imageUrl = item.imageUrl,
|
||||
name = item.name,
|
||||
showOverlay = true,
|
||||
favorite = item.favorite,
|
||||
imageUrl = imageUrl,
|
||||
name = name,
|
||||
showOverlay = false,
|
||||
favorite = favorite,
|
||||
watched = false,
|
||||
unwatchedCount = -1,
|
||||
numberOfVersions = -1,
|
||||
watchedPercent = null,
|
||||
useFallbackText = false,
|
||||
useFallbackText = true,
|
||||
contentScale = ContentScale.Crop,
|
||||
fallback = {
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.fillMaxSize()
|
||||
.align(Alignment.Center),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.fa_user),
|
||||
fontFamily = FontAwesome,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 64.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(8.dp)
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(AspectRatios.TALL), // TODO,
|
||||
.aspectRatio(AspectRatios.SQUARE)
|
||||
.clip(CircleShape),
|
||||
)
|
||||
}
|
||||
Column(
|
||||
|
|
@ -93,7 +171,7 @@ fun PersonCard(
|
|||
.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = item.name ?: "",
|
||||
text = name ?: "",
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier =
|
||||
|
|
@ -102,9 +180,9 @@ fun PersonCard(
|
|||
.padding(horizontal = 4.dp)
|
||||
.enableMarquee(focusedAfterDelay),
|
||||
)
|
||||
item.role?.let {
|
||||
role?.let {
|
||||
Text(
|
||||
text = item.role,
|
||||
text = role,
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier =
|
||||
|
|
@ -117,3 +195,24 @@ fun PersonCard(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewTvSpec
|
||||
@Composable
|
||||
private fun PersonCardPreview() {
|
||||
WholphinTheme {
|
||||
PersonCard(
|
||||
person =
|
||||
Person(
|
||||
id = UUID.randomUUID(),
|
||||
name = "John Smith",
|
||||
role = "Actor",
|
||||
type = PersonKind.ACTOR,
|
||||
imageUrl = null,
|
||||
favorite = false,
|
||||
),
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
modifier = Modifier.width(personRowCardWidth),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@ import androidx.compose.foundation.lazy.LazyRow
|
|||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
|
|
@ -21,8 +23,10 @@ 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.data.model.DiscoverItem
|
||||
import com.github.damontecres.wholphin.data.model.Person
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.rememberInt
|
||||
|
||||
@Composable
|
||||
fun PersonRow(
|
||||
|
|
@ -31,6 +35,57 @@ fun PersonRow(
|
|||
modifier: Modifier = Modifier,
|
||||
@StringRes title: Int = R.string.people,
|
||||
onLongClick: ((Int, Person) -> Unit)? = null,
|
||||
) {
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
var position by rememberInt()
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
LazyRow(
|
||||
state = rememberLazyListState(),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
contentPadding = PaddingValues(8.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 16.dp)
|
||||
.fillMaxWidth()
|
||||
.focusRestorer(firstFocus),
|
||||
) {
|
||||
itemsIndexed(people) { index, person ->
|
||||
PersonCard(
|
||||
person = person,
|
||||
onClick = {
|
||||
position = index
|
||||
onClick.invoke(person)
|
||||
},
|
||||
onLongClick = {
|
||||
position = index
|
||||
onLongClick?.invoke(index, person)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.width(personRowCardWidth)
|
||||
.ifElse(index == position, Modifier.focusRequester(firstFocus))
|
||||
.animateItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DiscoverPersonRow(
|
||||
people: List<DiscoverItem>,
|
||||
onClick: (DiscoverItem) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
@StringRes title: Int = R.string.people,
|
||||
onLongClick: ((Int, DiscoverItem) -> Unit)? = null,
|
||||
) {
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
Column(
|
||||
|
|
@ -52,14 +107,17 @@ fun PersonRow(
|
|||
.fillMaxWidth()
|
||||
.focusRestorer(firstFocus),
|
||||
) {
|
||||
itemsIndexed(people) { index, item ->
|
||||
itemsIndexed(people) { index, person ->
|
||||
PersonCard(
|
||||
item = item,
|
||||
onClick = { onClick.invoke(item) },
|
||||
onLongClick = { onLongClick?.invoke(index, item) },
|
||||
name = person.title,
|
||||
role = person.subtitle,
|
||||
imageUrl = person.posterUrl,
|
||||
favorite = false,
|
||||
onClick = { onClick.invoke(person) },
|
||||
onLongClick = { onLongClick?.invoke(index, person) },
|
||||
modifier =
|
||||
Modifier
|
||||
.width(120.dp)
|
||||
.width(personRowCardWidth)
|
||||
.ifElse(index == 0, Modifier.focusRequester(firstFocus))
|
||||
.animateItem(),
|
||||
)
|
||||
|
|
@ -67,3 +125,5 @@ fun PersonRow(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
val personRowCardWidth = 108.dp
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ fun SeasonCard(
|
|||
isPlayed = item?.data?.userData?.played ?: false,
|
||||
unplayedItemCount = item?.data?.userData?.unplayedItemCount ?: 0,
|
||||
playedPercentage = item?.data?.userData?.playedPercentage ?: 0.0,
|
||||
numberOfVersions = item?.data?.mediaSourceCount ?: 0,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = modifier,
|
||||
|
|
@ -112,6 +113,7 @@ fun SeasonCard(
|
|||
isPlayed: Boolean,
|
||||
unplayedItemCount: Int,
|
||||
playedPercentage: Double,
|
||||
numberOfVersions: Int,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
@ -172,6 +174,7 @@ fun SeasonCard(
|
|||
watched = isPlayed,
|
||||
unwatchedCount = unplayedItemCount,
|
||||
watchedPercent = playedPercentage,
|
||||
numberOfVersions = numberOfVersions,
|
||||
useFallbackText = false,
|
||||
modifier =
|
||||
Modifier
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import androidx.compose.runtime.setValue
|
|||
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.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
|
@ -39,6 +40,7 @@ import androidx.compose.ui.unit.Dp
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
|
|
@ -67,7 +69,7 @@ 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
|
||||
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
||||
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
|
||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
import com.github.damontecres.wholphin.ui.cards.GridCard
|
||||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||
|
|
@ -87,15 +89,17 @@ import com.github.damontecres.wholphin.ui.setValueOnMain
|
|||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetPersonsHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
|
|
@ -114,13 +118,13 @@ import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
|||
import timber.log.Timber
|
||||
import java.util.TreeSet
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration
|
||||
|
||||
@HiltViewModel
|
||||
@HiltViewModel(assistedFactory = CollectionFolderViewModel.Factory::class)
|
||||
class CollectionFolderViewModel
|
||||
@Inject
|
||||
@AssistedInject
|
||||
constructor(
|
||||
private val savedStateHandle: SavedStateHandle,
|
||||
api: ApiClient,
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val serverRepository: ServerRepository,
|
||||
|
|
@ -128,63 +132,75 @@ class CollectionFolderViewModel
|
|||
private val favoriteWatchManager: FavoriteWatchManager,
|
||||
private val backdropService: BackdropService,
|
||||
val navigationManager: NavigationManager,
|
||||
@Assisted itemId: String,
|
||||
@Assisted initialSortAndDirection: SortAndDirection?,
|
||||
@Assisted("recursive") private val recursive: Boolean,
|
||||
@Assisted private val collectionFilter: CollectionFolderFilter,
|
||||
@Assisted("useSeriesForPrimary") private val useSeriesForPrimary: Boolean,
|
||||
@Assisted defaultViewOptions: ViewOptions,
|
||||
) : ItemViewModel(api) {
|
||||
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(
|
||||
itemId: String,
|
||||
initialSortAndDirection: SortAndDirection?,
|
||||
@Assisted("recursive") recursive: Boolean,
|
||||
collectionFilter: CollectionFolderFilter,
|
||||
@Assisted("useSeriesForPrimary") useSeriesForPrimary: Boolean,
|
||||
defaultViewOptions: ViewOptions,
|
||||
): CollectionFolderViewModel
|
||||
}
|
||||
|
||||
val loading = MutableLiveData<DataLoadingState<List<BaseItem?>>>(DataLoadingState.Loading)
|
||||
val backgroundLoading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
||||
val pager = MutableLiveData<List<BaseItem?>>(listOf())
|
||||
val sortAndDirection = MutableLiveData<SortAndDirection>()
|
||||
val filter = MutableLiveData<GetItemsFilter>(GetItemsFilter())
|
||||
val viewOptions = MutableLiveData<ViewOptions>()
|
||||
|
||||
private var useSeriesForPrimary: Boolean = true
|
||||
private lateinit var collectionFilter: CollectionFolderFilter
|
||||
|
||||
fun init(
|
||||
itemId: String,
|
||||
initialSortAndDirection: SortAndDirection?,
|
||||
recursive: Boolean,
|
||||
collectionFilter: CollectionFolderFilter,
|
||||
useSeriesForPrimary: Boolean,
|
||||
defaultViewOptions: ViewOptions,
|
||||
): Job =
|
||||
viewModelScope.launch(
|
||||
LoadingExceptionHandler(
|
||||
loading,
|
||||
context.getString(R.string.error_loading_collection, itemId),
|
||||
) + Dispatchers.IO,
|
||||
) {
|
||||
this@CollectionFolderViewModel.collectionFilter = collectionFilter
|
||||
this@CollectionFolderViewModel.useSeriesForPrimary = useSeriesForPrimary
|
||||
this@CollectionFolderViewModel.itemId = itemId
|
||||
itemId.toUUIDOrNull()?.let {
|
||||
fetchItem(it)
|
||||
}
|
||||
|
||||
val libraryDisplayInfo =
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
libraryDisplayInfoDao.getItem(user, itemId)
|
||||
}
|
||||
this@CollectionFolderViewModel.viewOptions.setValueOnMain(
|
||||
libraryDisplayInfo?.viewOptions ?: defaultViewOptions,
|
||||
)
|
||||
|
||||
val sortAndDirection =
|
||||
if (collectionFilter.useSavedLibraryDisplayInfo) {
|
||||
libraryDisplayInfo?.sortAndDirection
|
||||
} else {
|
||||
null
|
||||
} ?: initialSortAndDirection ?: SortAndDirection.DEFAULT
|
||||
|
||||
val filterToUse =
|
||||
if (collectionFilter.useSavedLibraryDisplayInfo && libraryDisplayInfo?.filter != null) {
|
||||
collectionFilter.filter.merge(libraryDisplayInfo.filter)
|
||||
} else {
|
||||
collectionFilter.filter
|
||||
}
|
||||
|
||||
loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary)
|
||||
var position: Int
|
||||
get() = savedStateHandle.get<Int>("position") ?: 0
|
||||
set(value) {
|
||||
savedStateHandle["position"] = value
|
||||
}
|
||||
|
||||
init {
|
||||
viewModelScope.launchIO {
|
||||
super.itemId = itemId
|
||||
try {
|
||||
itemId.toUUIDOrNull()?.let {
|
||||
fetchItem(it)
|
||||
}
|
||||
|
||||
val libraryDisplayInfo =
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
libraryDisplayInfoDao.getItem(user, itemId)
|
||||
}
|
||||
this@CollectionFolderViewModel.viewOptions.setValueOnMain(
|
||||
libraryDisplayInfo?.viewOptions ?: defaultViewOptions,
|
||||
)
|
||||
|
||||
val sortAndDirection =
|
||||
if (collectionFilter.useSavedLibraryDisplayInfo) {
|
||||
libraryDisplayInfo?.sortAndDirection
|
||||
} else {
|
||||
null
|
||||
} ?: initialSortAndDirection ?: SortAndDirection.DEFAULT
|
||||
|
||||
val filterToUse =
|
||||
if (collectionFilter.useSavedLibraryDisplayInfo && libraryDisplayInfo?.filter != null) {
|
||||
collectionFilter.filter.merge(libraryDisplayInfo.filter)
|
||||
} else {
|
||||
collectionFilter.filter
|
||||
}
|
||||
|
||||
loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary)
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error during init")
|
||||
loading.setValueOnMain(DataLoadingState.Error(ex))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveLibraryDisplayInfo(
|
||||
newFilter: GetItemsFilter = this.filter.value!!,
|
||||
newSort: SortAndDirection = this.sortAndDirection.value!!,
|
||||
|
|
@ -192,7 +208,7 @@ class CollectionFolderViewModel
|
|||
) {
|
||||
if (collectionFilter.useSavedLibraryDisplayInfo) {
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launchIO {
|
||||
val libraryDisplayInfo =
|
||||
LibraryDisplayInfo(
|
||||
userId = user.rowId,
|
||||
|
|
@ -252,8 +268,7 @@ class CollectionFolderViewModel
|
|||
viewModelScope.launch(Dispatchers.IO) {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (resetState) {
|
||||
pager.value = listOf()
|
||||
loading.value = LoadingState.Loading
|
||||
loading.value = DataLoadingState.Loading
|
||||
}
|
||||
backgroundLoading.value = LoadingState.Loading
|
||||
this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection
|
||||
|
|
@ -264,8 +279,7 @@ class CollectionFolderViewModel
|
|||
createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init()
|
||||
if (newPager.isNotEmpty()) newPager.getBlocking(0)
|
||||
withContext(Dispatchers.Main) {
|
||||
pager.value = newPager
|
||||
loading.value = LoadingState.Success
|
||||
loading.value = DataLoadingState.Success(newPager)
|
||||
backgroundLoading.value = LoadingState.Success
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
|
|
@ -276,8 +290,7 @@ class CollectionFolderViewModel
|
|||
filter,
|
||||
)
|
||||
withContext(Dispatchers.Main) {
|
||||
loading.value = LoadingState.Error(ex)
|
||||
pager.value = listOf()
|
||||
loading.value = DataLoadingState.Error(ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -288,50 +301,14 @@ class CollectionFolderViewModel
|
|||
recursive: Boolean,
|
||||
filter: GetItemsFilter,
|
||||
useSeriesForPrimary: Boolean,
|
||||
): ApiRequestPager<out Any> {
|
||||
val item = item.value
|
||||
return when (filter.override) {
|
||||
): ApiRequestPager<out Any> =
|
||||
when (filter.override) {
|
||||
GetItemsFilterOverride.NONE -> {
|
||||
val includeItemTypes =
|
||||
item
|
||||
?.data
|
||||
?.collectionType
|
||||
?.baseItemKinds
|
||||
.orEmpty()
|
||||
val request =
|
||||
filter.applyTo(
|
||||
GetItemsRequest(
|
||||
parentId = item?.id,
|
||||
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
|
||||
includeItemTypes = includeItemTypes,
|
||||
recursive = recursive,
|
||||
excludeItemIds = item?.let { listOf(item.id) },
|
||||
sortBy =
|
||||
buildList {
|
||||
if (sortAndDirection.sort != ItemSortBy.DEFAULT) {
|
||||
add(sortAndDirection.sort)
|
||||
if (sortAndDirection.sort != ItemSortBy.SORT_NAME) {
|
||||
add(ItemSortBy.SORT_NAME)
|
||||
}
|
||||
if (item?.data?.collectionType == CollectionType.MOVIES) {
|
||||
add(ItemSortBy.PRODUCTION_YEAR)
|
||||
}
|
||||
}
|
||||
},
|
||||
sortOrder =
|
||||
buildList {
|
||||
if (sortAndDirection.sort != ItemSortBy.DEFAULT) {
|
||||
add(sortAndDirection.direction)
|
||||
if (sortAndDirection.sort != ItemSortBy.SORT_NAME) {
|
||||
add(SortOrder.ASCENDING)
|
||||
}
|
||||
if (item?.data?.collectionType == CollectionType.MOVIES) {
|
||||
add(SortOrder.ASCENDING)
|
||||
}
|
||||
}
|
||||
},
|
||||
fields = SlimItemFields,
|
||||
),
|
||||
createGetItemsRequest(
|
||||
sortAndDirection = sortAndDirection,
|
||||
recursive = recursive,
|
||||
filter = filter,
|
||||
)
|
||||
val newPager =
|
||||
ApiRequestPager(
|
||||
|
|
@ -362,6 +339,55 @@ class CollectionFolderViewModel
|
|||
newPager
|
||||
}
|
||||
}
|
||||
|
||||
private fun createGetItemsRequest(
|
||||
sortAndDirection: SortAndDirection,
|
||||
recursive: Boolean,
|
||||
filter: GetItemsFilter,
|
||||
): GetItemsRequest {
|
||||
val item = item.value
|
||||
val includeItemTypes =
|
||||
item
|
||||
?.data
|
||||
?.collectionType
|
||||
?.baseItemKinds
|
||||
.orEmpty()
|
||||
val request =
|
||||
filter.applyTo(
|
||||
GetItemsRequest(
|
||||
parentId = item?.id,
|
||||
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
|
||||
includeItemTypes = includeItemTypes,
|
||||
recursive = recursive,
|
||||
excludeItemIds = item?.let { listOf(item.id) },
|
||||
sortBy =
|
||||
buildList {
|
||||
if (sortAndDirection.sort != ItemSortBy.DEFAULT) {
|
||||
add(sortAndDirection.sort)
|
||||
if (sortAndDirection.sort != ItemSortBy.SORT_NAME) {
|
||||
add(ItemSortBy.SORT_NAME)
|
||||
}
|
||||
if (item?.data?.collectionType == CollectionType.MOVIES) {
|
||||
add(ItemSortBy.PRODUCTION_YEAR)
|
||||
}
|
||||
}
|
||||
},
|
||||
sortOrder =
|
||||
buildList {
|
||||
if (sortAndDirection.sort != ItemSortBy.DEFAULT) {
|
||||
add(sortAndDirection.direction)
|
||||
if (sortAndDirection.sort != ItemSortBy.SORT_NAME) {
|
||||
add(SortOrder.ASCENDING)
|
||||
}
|
||||
if (item?.data?.collectionType == CollectionType.MOVIES) {
|
||||
add(SortOrder.ASCENDING)
|
||||
}
|
||||
}
|
||||
},
|
||||
fields = SlimItemFields,
|
||||
),
|
||||
)
|
||||
return request
|
||||
}
|
||||
|
||||
suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> =
|
||||
|
|
@ -441,26 +467,25 @@ class CollectionFolderViewModel
|
|||
|
||||
suspend fun positionOfLetter(letter: Char): Int? =
|
||||
withContext(Dispatchers.IO) {
|
||||
item.value?.let { item ->
|
||||
val includeItemTypes =
|
||||
when (item.data.collectionType) {
|
||||
CollectionType.MOVIES -> listOf(BaseItemKind.MOVIE)
|
||||
CollectionType.TVSHOWS -> listOf(BaseItemKind.SERIES)
|
||||
CollectionType.HOMEVIDEOS -> listOf(BaseItemKind.VIDEO)
|
||||
else -> listOf()
|
||||
}
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
parentId = item.id,
|
||||
includeItemTypes = includeItemTypes,
|
||||
nameLessThan = letter.toString(),
|
||||
limit = 0,
|
||||
enableTotalRecordCount = true,
|
||||
recursive = true,
|
||||
)
|
||||
val result by GetItemsRequestHandler.execute(api, request)
|
||||
result.totalRecordCount
|
||||
val sort = sortAndDirection.value
|
||||
val filter = filter.value
|
||||
if (sort == null || filter == null) {
|
||||
return@withContext null
|
||||
}
|
||||
val request =
|
||||
createGetItemsRequest(
|
||||
sortAndDirection = sort,
|
||||
recursive = recursive,
|
||||
filter = filter,
|
||||
).copy(
|
||||
enableImageTypes = null,
|
||||
fields = null,
|
||||
nameLessThan = letter.toString(),
|
||||
limit = 0,
|
||||
enableTotalRecordCount = true,
|
||||
)
|
||||
val result by GetItemsRequestHandler.execute(api, request)
|
||||
result.totalRecordCount
|
||||
}
|
||||
|
||||
fun setWatched(
|
||||
|
|
@ -469,7 +494,9 @@ class CollectionFolderViewModel
|
|||
played: Boolean,
|
||||
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||
favoriteWatchManager.setWatched(itemId, played)
|
||||
(pager.value as? ApiRequestPager<*>)?.refreshItem(position, itemId)
|
||||
(loading.value as? DataLoadingState.Success)?.let {
|
||||
(it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId)
|
||||
}
|
||||
}
|
||||
|
||||
fun setFavorite(
|
||||
|
|
@ -478,7 +505,9 @@ class CollectionFolderViewModel
|
|||
favorite: Boolean,
|
||||
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||
favoriteWatchManager.setFavorite(itemId, favorite)
|
||||
(pager.value as? ApiRequestPager<*>)?.refreshItem(position, itemId)
|
||||
(loading.value as? DataLoadingState.Success)?.let {
|
||||
(it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateBackdrop(item: BaseItem) {
|
||||
|
|
@ -504,11 +533,13 @@ fun CollectionFolderGrid(
|
|||
playEnabled: Boolean,
|
||||
defaultViewOptions: ViewOptions,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModelKey: String? = itemId.toServerString(),
|
||||
initialSortAndDirection: SortAndDirection? = null,
|
||||
showTitle: Boolean = true,
|
||||
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
||||
useSeriesForPrimary: Boolean = true,
|
||||
filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions,
|
||||
focusRequesterOnEmpty: FocusRequester? = null,
|
||||
) = CollectionFolderGrid(
|
||||
preferences,
|
||||
itemId.toServerString(),
|
||||
|
|
@ -517,6 +548,7 @@ fun CollectionFolderGrid(
|
|||
onClickItem,
|
||||
sortOptions,
|
||||
playEnabled,
|
||||
viewModelKey = viewModelKey,
|
||||
defaultViewOptions = defaultViewOptions,
|
||||
modifier = modifier,
|
||||
initialSortAndDirection = initialSortAndDirection,
|
||||
|
|
@ -524,6 +556,7 @@ fun CollectionFolderGrid(
|
|||
positionCallback = positionCallback,
|
||||
useSeriesForPrimary = useSeriesForPrimary,
|
||||
filterOptions = filterOptions,
|
||||
focusRequesterOnEmpty = focusRequesterOnEmpty,
|
||||
)
|
||||
|
||||
@Composable
|
||||
|
|
@ -537,31 +570,34 @@ fun CollectionFolderGrid(
|
|||
playEnabled: Boolean,
|
||||
defaultViewOptions: ViewOptions,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: CollectionFolderViewModel = hiltViewModel(key = itemId),
|
||||
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||
viewModelKey: String? = itemId,
|
||||
initialSortAndDirection: SortAndDirection? = null,
|
||||
showTitle: Boolean = true,
|
||||
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
||||
useSeriesForPrimary: Boolean = true,
|
||||
filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions,
|
||||
focusRequesterOnEmpty: FocusRequester? = null,
|
||||
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||
viewModel: CollectionFolderViewModel =
|
||||
hiltViewModel<CollectionFolderViewModel, CollectionFolderViewModel.Factory>(
|
||||
key = viewModelKey,
|
||||
) {
|
||||
it.create(
|
||||
itemId = itemId,
|
||||
initialSortAndDirection = initialSortAndDirection,
|
||||
recursive = recursive,
|
||||
collectionFilter = initialFilter,
|
||||
useSeriesForPrimary = useSeriesForPrimary,
|
||||
defaultViewOptions = defaultViewOptions,
|
||||
)
|
||||
},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
OneTimeLaunchedEffect {
|
||||
viewModel.init(
|
||||
itemId,
|
||||
initialSortAndDirection,
|
||||
recursive,
|
||||
initialFilter,
|
||||
useSeriesForPrimary,
|
||||
defaultViewOptions,
|
||||
)
|
||||
}
|
||||
val sortAndDirection by viewModel.sortAndDirection.observeAsState(SortAndDirection.DEFAULT)
|
||||
val filter by viewModel.filter.observeAsState(initialFilter.filter)
|
||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||
val backgroundLoading by viewModel.backgroundLoading.observeAsState(LoadingState.Loading)
|
||||
val item by viewModel.item.observeAsState()
|
||||
val pager by viewModel.pager.observeAsState()
|
||||
val viewOptions by viewModel.viewOptions.observeAsState(defaultViewOptions)
|
||||
|
||||
var moreDialog by remember { mutableStateOf<Optional<PositionItem>>(Optional.absent()) }
|
||||
|
|
@ -569,90 +605,91 @@ fun CollectionFolderGrid(
|
|||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
|
||||
when (val state = loading) {
|
||||
is LoadingState.Error -> {
|
||||
ErrorMessage(state)
|
||||
}
|
||||
|
||||
LoadingState.Loading,
|
||||
LoadingState.Pending,
|
||||
DataLoadingState.Loading,
|
||||
DataLoadingState.Pending,
|
||||
-> {
|
||||
LoadingPage()
|
||||
}
|
||||
|
||||
LoadingState.Success -> {
|
||||
pager?.let { pager ->
|
||||
val title =
|
||||
initialFilter.nameOverride
|
||||
?: item?.name
|
||||
?: item?.data?.collectionType?.name
|
||||
?: stringResource(R.string.collection)
|
||||
Box(modifier = modifier) {
|
||||
CollectionFolderGridContent(
|
||||
preferences = preferences,
|
||||
item = item,
|
||||
title = title,
|
||||
pager = pager,
|
||||
sortAndDirection = sortAndDirection!!,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onClickItem = onClickItem,
|
||||
onLongClickItem = { position, item ->
|
||||
moreDialog.makePresent(PositionItem(position, item))
|
||||
},
|
||||
onSortChange = {
|
||||
viewModel.onSortChange(it, recursive, filter)
|
||||
},
|
||||
filterOptions = filterOptions,
|
||||
currentFilter = filter,
|
||||
onFilterChange = {
|
||||
viewModel.onFilterChange(it, recursive)
|
||||
},
|
||||
getPossibleFilterValues = {
|
||||
viewModel.getFilterOptionValues(it)
|
||||
},
|
||||
showTitle = showTitle,
|
||||
sortOptions = sortOptions,
|
||||
positionCallback = positionCallback,
|
||||
letterPosition = { viewModel.positionOfLetter(it) ?: -1 },
|
||||
viewOptions = viewOptions,
|
||||
defaultViewOptions = defaultViewOptions,
|
||||
onSaveViewOptions = { viewModel.saveViewOptions(it) },
|
||||
onChangeBackdrop = viewModel::updateBackdrop,
|
||||
playEnabled = playEnabled,
|
||||
onClickPlay = { _, item ->
|
||||
viewModel.navigationManager.navigateTo(Destination.Playback(item))
|
||||
},
|
||||
onClickPlayAll = { shuffle ->
|
||||
itemId.toUUIDOrNull()?.let {
|
||||
viewModel.navigationManager.navigateTo(
|
||||
Destination.PlaybackList(
|
||||
itemId = it,
|
||||
startIndex = 0,
|
||||
shuffle = shuffle,
|
||||
recursive = recursive,
|
||||
sortAndDirection = sortAndDirection,
|
||||
filter = filter,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
is DataLoadingState.Error,
|
||||
is DataLoadingState.Success<*>,
|
||||
-> {
|
||||
val title =
|
||||
initialFilter.nameOverride
|
||||
?: item?.name
|
||||
?: item?.data?.collectionType?.name
|
||||
?: stringResource(R.string.collection)
|
||||
Box(modifier = modifier) {
|
||||
CollectionFolderGridContent(
|
||||
preferences = preferences,
|
||||
initialPosition = viewModel.position,
|
||||
item = item,
|
||||
title = title,
|
||||
loadingState = state as DataLoadingState<List<BaseItem?>>,
|
||||
sortAndDirection = sortAndDirection!!,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
focusRequesterOnEmpty = focusRequesterOnEmpty,
|
||||
onClickItem = onClickItem,
|
||||
onLongClickItem = { position, item ->
|
||||
moreDialog.makePresent(PositionItem(position, item))
|
||||
},
|
||||
onSortChange = {
|
||||
viewModel.onSortChange(it, recursive, filter)
|
||||
},
|
||||
filterOptions = filterOptions,
|
||||
currentFilter = filter,
|
||||
onFilterChange = {
|
||||
viewModel.onFilterChange(it, recursive)
|
||||
},
|
||||
getPossibleFilterValues = {
|
||||
viewModel.getFilterOptionValues(it)
|
||||
},
|
||||
showTitle = showTitle,
|
||||
sortOptions = sortOptions,
|
||||
positionCallback = { columns, position ->
|
||||
viewModel.position = position
|
||||
positionCallback?.invoke(columns, position)
|
||||
},
|
||||
letterPosition = { viewModel.positionOfLetter(it) ?: -1 },
|
||||
viewOptions = viewOptions,
|
||||
defaultViewOptions = defaultViewOptions,
|
||||
onSaveViewOptions = { viewModel.saveViewOptions(it) },
|
||||
onChangeBackdrop = viewModel::updateBackdrop,
|
||||
playEnabled = playEnabled,
|
||||
onClickPlay = { _, item ->
|
||||
viewModel.navigationManager.navigateTo(Destination.Playback(item))
|
||||
},
|
||||
onClickPlayAll = { shuffle ->
|
||||
itemId.toUUIDOrNull()?.let {
|
||||
viewModel.navigationManager.navigateTo(
|
||||
Destination.PlaybackList(
|
||||
itemId = it,
|
||||
startIndex = 0,
|
||||
shuffle = shuffle,
|
||||
recursive = recursive,
|
||||
sortAndDirection = sortAndDirection,
|
||||
filter = filter,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
backgroundLoading == LoadingState.Loading,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(16.dp),
|
||||
) {
|
||||
CircularProgress(
|
||||
Modifier
|
||||
.background(
|
||||
MaterialTheme.colorScheme.background.copy(alpha = .25f),
|
||||
shape = CircleShape,
|
||||
).size(64.dp)
|
||||
.padding(4.dp),
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(
|
||||
backgroundLoading == LoadingState.Loading,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(16.dp),
|
||||
) {
|
||||
CircularProgress(
|
||||
Modifier
|
||||
.background(
|
||||
MaterialTheme.colorScheme.background.copy(alpha = .25f),
|
||||
shape = CircleShape,
|
||||
).size(64.dp)
|
||||
.padding(4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -713,7 +750,7 @@ fun CollectionFolderGridContent(
|
|||
preferences: UserPreferences,
|
||||
item: BaseItem?,
|
||||
title: String,
|
||||
pager: List<BaseItem?>,
|
||||
loadingState: DataLoadingState<List<BaseItem?>>,
|
||||
sortAndDirection: SortAndDirection,
|
||||
onClickItem: (Int, BaseItem) -> Unit,
|
||||
onLongClickItem: (Int, BaseItem) -> Unit,
|
||||
|
|
@ -728,25 +765,35 @@ fun CollectionFolderGridContent(
|
|||
onClickPlayAll: (shuffle: Boolean) -> Unit,
|
||||
onClickPlay: (Int, BaseItem) -> Unit,
|
||||
onChangeBackdrop: (BaseItem) -> Unit,
|
||||
initialPosition: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
showTitle: Boolean = true,
|
||||
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
||||
currentFilter: GetItemsFilter = GetItemsFilter(),
|
||||
filterOptions: List<ItemFilterBy<*>> = listOf(),
|
||||
onFilterChange: (GetItemsFilter) -> Unit = {},
|
||||
focusRequesterOnEmpty: FocusRequester? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val pager = (loadingState as? DataLoadingState.Success)?.data
|
||||
var showHeader by rememberSaveable { mutableStateOf(true) }
|
||||
var showViewOptions by rememberSaveable { mutableStateOf(false) }
|
||||
var viewOptions by remember { mutableStateOf(viewOptions) }
|
||||
val headerRowFocusRequester = remember { FocusRequester() }
|
||||
|
||||
val gridFocusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() }
|
||||
if (pager?.isNotEmpty() == true) {
|
||||
RequestOrRestoreFocus(gridFocusRequester)
|
||||
} else {
|
||||
LaunchedEffect(Unit) {
|
||||
(focusRequesterOnEmpty ?: headerRowFocusRequester).tryRequestFocus()
|
||||
}
|
||||
}
|
||||
var backdropImageUrl by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
var position by rememberInt(0)
|
||||
val focusedItem = pager.getOrNull(position)
|
||||
var position by rememberInt(initialPosition)
|
||||
val focusedItem = pager?.getOrNull(position)
|
||||
if (viewOptions.showDetails) {
|
||||
LaunchedEffect(focusedItem) {
|
||||
focusedItem?.let(onChangeBackdrop)
|
||||
|
|
@ -759,7 +806,7 @@ fun CollectionFolderGridContent(
|
|||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
showHeader,
|
||||
showHeader || loadingState !is DataLoadingState.Success,
|
||||
enter = slideInVertically() + fadeIn(),
|
||||
exit = slideOutVertically() + fadeOut(),
|
||||
) {
|
||||
|
|
@ -786,7 +833,8 @@ fun CollectionFolderGridContent(
|
|||
modifier =
|
||||
Modifier
|
||||
.padding(start = 16.dp, end = endPadding)
|
||||
.fillMaxWidth(),
|
||||
.fillMaxWidth()
|
||||
.focusRequester(headerRowFocusRequester),
|
||||
) {
|
||||
if (sortOptions.isNotEmpty() || filterOptions.isNotEmpty()) {
|
||||
Row(
|
||||
|
|
@ -819,7 +867,7 @@ fun CollectionFolderGridContent(
|
|||
)
|
||||
}
|
||||
}
|
||||
if (playEnabled) {
|
||||
if (playEnabled && pager?.isNotEmpty() == true) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
|
@ -851,47 +899,62 @@ fun CollectionFolderGridContent(
|
|||
.padding(16.dp),
|
||||
)
|
||||
}
|
||||
CardGrid(
|
||||
pager = pager,
|
||||
onClickItem = onClickItem,
|
||||
onLongClickItem = onLongClickItem,
|
||||
onClickPlay = onClickPlay,
|
||||
letterPosition = letterPosition,
|
||||
gridFocusRequester = gridFocusRequester,
|
||||
showJumpButtons = false, // TODO add preference
|
||||
showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
initialPosition = 0,
|
||||
positionCallback = { columns, newPosition ->
|
||||
showHeader = newPosition < columns
|
||||
position = newPosition
|
||||
positionCallback?.invoke(columns, newPosition)
|
||||
},
|
||||
cardContent = { item, onClick, onLongClick, mod ->
|
||||
GridCard(
|
||||
item = item,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
imageContentScale = viewOptions.contentScale.scale,
|
||||
imageAspectRatio = viewOptions.aspectRatio.ratio,
|
||||
imageType = viewOptions.imageType,
|
||||
showTitle = viewOptions.showTitles,
|
||||
modifier = mod,
|
||||
when (val state = loadingState) {
|
||||
DataLoadingState.Pending,
|
||||
DataLoadingState.Loading,
|
||||
-> {
|
||||
// This shouldn't happen, so just show placeholder
|
||||
Text("Loading")
|
||||
}
|
||||
|
||||
is DataLoadingState.Error -> {
|
||||
ErrorMessage(state.message, state.exception)
|
||||
}
|
||||
|
||||
is DataLoadingState.Success<List<BaseItem?>> -> {
|
||||
CardGrid(
|
||||
pager = state.data,
|
||||
onClickItem = onClickItem,
|
||||
onLongClickItem = onLongClickItem,
|
||||
onClickPlay = onClickPlay,
|
||||
letterPosition = letterPosition,
|
||||
gridFocusRequester = gridFocusRequester,
|
||||
showJumpButtons = false, // TODO add preference
|
||||
showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
initialPosition = initialPosition,
|
||||
positionCallback = { columns, newPosition ->
|
||||
showHeader = newPosition < columns
|
||||
position = newPosition
|
||||
positionCallback?.invoke(columns, newPosition)
|
||||
},
|
||||
cardContent = { item, onClick, onLongClick, mod ->
|
||||
GridCard(
|
||||
item = item,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
imageContentScale = viewOptions.contentScale.scale,
|
||||
imageAspectRatio = viewOptions.aspectRatio.ratio,
|
||||
imageType = viewOptions.imageType,
|
||||
showTitle = viewOptions.showTitles,
|
||||
modifier = mod,
|
||||
)
|
||||
},
|
||||
columns = viewOptions.columns,
|
||||
spacing = viewOptions.spacing.dp,
|
||||
)
|
||||
},
|
||||
columns = viewOptions.columns,
|
||||
spacing = viewOptions.spacing.dp,
|
||||
)
|
||||
AnimatedVisibility(showViewOptions) {
|
||||
ViewOptionsDialog(
|
||||
viewOptions = viewOptions,
|
||||
defaultViewOptions = defaultViewOptions,
|
||||
onDismissRequest = {
|
||||
showViewOptions = false
|
||||
onSaveViewOptions.invoke(viewOptions)
|
||||
},
|
||||
onViewOptionsChange = { viewOptions = it },
|
||||
)
|
||||
AnimatedVisibility(showViewOptions) {
|
||||
ViewOptionsDialog(
|
||||
viewOptions = viewOptions,
|
||||
defaultViewOptions = defaultViewOptions,
|
||||
onDismissRequest = {
|
||||
showViewOptions = false
|
||||
onSaveViewOptions.invoke(viewOptions)
|
||||
},
|
||||
onViewOptionsChange = { viewOptions = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import androidx.compose.foundation.gestures.scrollBy
|
|||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
|
|
@ -55,6 +56,7 @@ import com.github.damontecres.wholphin.R
|
|||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.playback.SimpleMediaStream
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -83,6 +85,7 @@ data class DialogItem(
|
|||
val leadingContent: @Composable (BoxScope.() -> Unit)? = null,
|
||||
val trailingContent: @Composable (() -> Unit)? = null,
|
||||
val enabled: Boolean = true,
|
||||
val selected: Boolean = false,
|
||||
) : DialogItemEntry {
|
||||
constructor(
|
||||
@StringRes text: Int,
|
||||
|
|
@ -239,47 +242,48 @@ fun DialogPopupContent(
|
|||
) {
|
||||
val elevatedContainerColor =
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(elevation)
|
||||
LazyColumn(
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier =
|
||||
modifier
|
||||
// .widthIn(min = 520.dp, max = 300.dp)
|
||||
// .dialogFocusable()
|
||||
.graphicsLayer {
|
||||
this.clip = true
|
||||
this.shape = RoundedCornerShape(28.0.dp)
|
||||
}.drawBehind { drawRect(color = elevatedContainerColor) }
|
||||
.padding(PaddingValues(24.dp)),
|
||||
) {
|
||||
stickyHeader {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
items(dialogItems) {
|
||||
when (it) {
|
||||
is DialogItemDivider -> {
|
||||
HorizontalDivider(Modifier.height(16.dp))
|
||||
}
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
LazyColumn(
|
||||
modifier = Modifier,
|
||||
) {
|
||||
items(dialogItems) {
|
||||
when (it) {
|
||||
is DialogItemDivider -> {
|
||||
HorizontalDivider(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
is DialogItem -> {
|
||||
ListItem(
|
||||
selected = false,
|
||||
enabled = !waiting && it.enabled,
|
||||
onClick = {
|
||||
if (dismissOnClick) {
|
||||
onDismissRequest.invoke()
|
||||
}
|
||||
it.onClick.invoke()
|
||||
},
|
||||
headlineContent = it.headlineContent,
|
||||
overlineContent = it.overlineContent,
|
||||
supportingContent = it.supportingContent,
|
||||
leadingContent = it.leadingContent,
|
||||
trailingContent = it.trailingContent,
|
||||
modifier = Modifier,
|
||||
)
|
||||
is DialogItem -> {
|
||||
ListItem(
|
||||
selected = it.selected,
|
||||
enabled = !waiting && it.enabled,
|
||||
onClick = {
|
||||
if (dismissOnClick) {
|
||||
onDismissRequest.invoke()
|
||||
}
|
||||
it.onClick.invoke()
|
||||
},
|
||||
headlineContent = it.headlineContent,
|
||||
overlineContent = it.overlineContent,
|
||||
supportingContent = it.supportingContent,
|
||||
leadingContent = it.leadingContent,
|
||||
trailingContent = it.trailingContent,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -500,6 +504,7 @@ fun resourceFor(type: MediaStreamType): Int =
|
|||
fun chooseStream(
|
||||
context: Context,
|
||||
streams: List<MediaStream>,
|
||||
currentIndex: Int?,
|
||||
type: MediaStreamType,
|
||||
onClick: (Int) -> Unit,
|
||||
): DialogParams =
|
||||
|
|
@ -511,6 +516,10 @@ fun chooseStream(
|
|||
if (type == MediaStreamType.SUBTITLE) {
|
||||
add(
|
||||
DialogItem(
|
||||
selected = currentIndex == null,
|
||||
leadingContent = {
|
||||
SelectedLeadingContent(currentIndex == null)
|
||||
},
|
||||
headlineContent = {
|
||||
Text(text = stringResource(R.string.none))
|
||||
},
|
||||
|
|
@ -519,15 +528,30 @@ fun chooseStream(
|
|||
onClick = { onClick.invoke(TrackIndex.DISABLED) },
|
||||
),
|
||||
)
|
||||
add(
|
||||
DialogItem(
|
||||
headlineContent = {
|
||||
Text(text = stringResource(R.string.only_forced_subtitles))
|
||||
},
|
||||
supportingContent = {
|
||||
},
|
||||
onClick = { onClick.invoke(TrackIndex.ONLY_FORCED) },
|
||||
),
|
||||
)
|
||||
}
|
||||
addAll(
|
||||
streams.filter { it.type == type }.mapIndexed { index, stream ->
|
||||
val title = stream.displayTitle ?: stream.title ?: ""
|
||||
val simpleStream = SimpleMediaStream.from(context, stream, true)
|
||||
DialogItem(
|
||||
selected = currentIndex == stream.index,
|
||||
leadingContent = {
|
||||
SelectedLeadingContent(currentIndex == stream.index)
|
||||
},
|
||||
headlineContent = {
|
||||
Text(text = title)
|
||||
Text(text = simpleStream.streamTitle ?: simpleStream.displayTitle)
|
||||
},
|
||||
supportingContent = {
|
||||
if (simpleStream.streamTitle != null) Text(text = simpleStream.displayTitle)
|
||||
},
|
||||
onClick = { onClick.invoke(stream.index) },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,123 +0,0 @@
|
|||
/*
|
||||
* Copyright 2023 Google LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.github.damontecres.wholphin.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.LocalTextStyle
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
|
||||
@Composable
|
||||
fun DotSeparatedRow(
|
||||
texts: List<String>,
|
||||
communityRating: Float? = null,
|
||||
criticRating: Float? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
textStyle: TextStyle = MaterialTheme.typography.titleSmall,
|
||||
) {
|
||||
CompositionLocalProvider(LocalTextStyle provides textStyle) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
texts.forEachIndexed { index, text ->
|
||||
Text(
|
||||
text = text,
|
||||
style = textStyle,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
)
|
||||
if (communityRating != null || criticRating != null || index != texts.lastIndex) {
|
||||
Dot()
|
||||
}
|
||||
}
|
||||
val height = with(LocalDensity.current) { textStyle.fontSize.toDp() }
|
||||
communityRating?.let {
|
||||
SimpleStarRating(
|
||||
communityRating = it,
|
||||
modifier = Modifier.height(height),
|
||||
)
|
||||
if (criticRating != null) {
|
||||
Dot()
|
||||
}
|
||||
}
|
||||
criticRating?.let {
|
||||
TomatoRating(it, Modifier.height(height))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Dot(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.onSurface.copy(alpha = 1f))
|
||||
.size(4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewTvSpec
|
||||
@Composable
|
||||
private fun DotSeparatedRowPreview() {
|
||||
WholphinTheme {
|
||||
Column {
|
||||
DotSeparatedRow(
|
||||
texts = listOf("2025", "1h 48m", "PG-13", "1h 30m left"),
|
||||
communityRating = null,
|
||||
criticRating = .75f,
|
||||
modifier = Modifier,
|
||||
textStyle = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
DotSeparatedRow(
|
||||
texts = listOf("2025", "1h 48m", "PG-13", "1h 30m left"),
|
||||
communityRating = 7.5f,
|
||||
criticRating = .75f,
|
||||
modifier = Modifier,
|
||||
textStyle = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
DotSeparatedRow(
|
||||
texts = listOf("2025", "1h 48m", "PG-13", "1h 30m left 7.5"),
|
||||
communityRating = 7.5f,
|
||||
criticRating = .45f,
|
||||
modifier = Modifier,
|
||||
textStyle = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,10 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.times
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
|
@ -33,6 +37,9 @@ 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.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
|
|
@ -51,26 +58,32 @@ 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
|
||||
@HiltViewModel(assistedFactory = GenreViewModel.Factory::class)
|
||||
class GenreViewModel
|
||||
@Inject
|
||||
@AssistedInject
|
||||
constructor(
|
||||
private val api: ApiClient,
|
||||
private val imageUrlService: ImageUrlService,
|
||||
private val serverRepository: ServerRepository,
|
||||
val navigationManager: NavigationManager,
|
||||
@Assisted private val itemId: UUID,
|
||||
@Assisted private val includeItemTypes: List<BaseItemKind>?,
|
||||
) : ViewModel() {
|
||||
private lateinit var itemId: UUID
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(
|
||||
itemId: UUID,
|
||||
includeItemTypes: List<BaseItemKind>?,
|
||||
): GenreViewModel
|
||||
}
|
||||
|
||||
val item = MutableLiveData<BaseItem?>(null)
|
||||
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||
val genres = MutableLiveData<List<Genre>>(listOf())
|
||||
|
||||
fun init(itemId: UUID) {
|
||||
fun init(cardWidthPx: Int) {
|
||||
loading.value = LoadingState.Loading
|
||||
this.itemId = itemId
|
||||
viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to fetch genres")) {
|
||||
val item =
|
||||
api.userLibraryApi.getItem(itemId = itemId).content.let {
|
||||
|
|
@ -82,6 +95,7 @@ class GenreViewModel
|
|||
userId = serverRepository.currentUser.value?.id,
|
||||
parentId = itemId,
|
||||
fields = SlimItemFields,
|
||||
includeItemTypes = includeItemTypes,
|
||||
)
|
||||
val genres =
|
||||
GetGenresRequestHandler
|
||||
|
|
@ -90,13 +104,11 @@ class GenreViewModel
|
|||
.map {
|
||||
Genre(it.id, it.name ?: "", null, Color.Black)
|
||||
}
|
||||
// val pager = ApiRequestPager(api, request, GetGenresRequestHandler, viewModelScope).init()
|
||||
withContext(Dispatchers.Main) {
|
||||
this@GenreViewModel.genres.value = genres
|
||||
loading.value = LoadingState.Success
|
||||
}
|
||||
// val excludeItemIds = mutableSetOf<UUID>()
|
||||
val genreToUrl = ConcurrentHashMap<UUID, String>()
|
||||
val genreToUrl = ConcurrentHashMap<UUID, String?>()
|
||||
val semaphore = Semaphore(4)
|
||||
genres
|
||||
.map { genre ->
|
||||
|
|
@ -107,33 +119,29 @@ class GenreViewModel
|
|||
.execute(
|
||||
api,
|
||||
GetItemsRequest(
|
||||
// excludeItemIds = excludeItemIds,
|
||||
parentId = itemId,
|
||||
recursive = true,
|
||||
limit = 1,
|
||||
sortBy = listOf(ItemSortBy.RANDOM),
|
||||
fields = listOf(ItemFields.GENRES),
|
||||
imageTypes = listOf(ImageType.THUMB),
|
||||
imageTypes = listOf(ImageType.BACKDROP),
|
||||
imageTypeLimit = 1,
|
||||
includeItemTypes =
|
||||
listOf(
|
||||
BaseItemKind.MOVIE,
|
||||
BaseItemKind.SERIES,
|
||||
),
|
||||
includeItemTypes = includeItemTypes,
|
||||
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,
|
||||
itemId = item.id,
|
||||
itemType = item.type,
|
||||
seriesId = null,
|
||||
useSeriesForPrimary = true,
|
||||
imageType = ImageType.BACKDROP,
|
||||
imageTags = item.imageTags.orEmpty(),
|
||||
fillWidth = cardWidthPx,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -164,11 +172,12 @@ class GenreViewModel
|
|||
}
|
||||
|
||||
data class Genre(
|
||||
override val id: UUID,
|
||||
val id: UUID,
|
||||
val name: String,
|
||||
val imageUrl: String?,
|
||||
val color: Color,
|
||||
) : CardGridItem {
|
||||
override val gridId: String get() = id.toString()
|
||||
override val playable: Boolean = false
|
||||
override val sortName: String get() = name
|
||||
}
|
||||
|
|
@ -176,11 +185,30 @@ data class Genre(
|
|||
@Composable
|
||||
fun GenreCardGrid(
|
||||
itemId: UUID,
|
||||
includeItemTypes: List<BaseItemKind>?,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: GenreViewModel = hiltViewModel(),
|
||||
viewModel: GenreViewModel =
|
||||
hiltViewModel<GenreViewModel, GenreViewModel.Factory>(
|
||||
creationCallback = { it.create(itemId, includeItemTypes) },
|
||||
),
|
||||
) {
|
||||
val columns = 4
|
||||
val spacing = 16.dp
|
||||
val density = LocalDensity.current
|
||||
val configuration = LocalConfiguration.current
|
||||
val cardWidthPx =
|
||||
remember {
|
||||
with(density) {
|
||||
// Grid has 16dp padding on either side & 16dp spacing between 4 cards
|
||||
// This isn't exact though because it doesn't account for nav drawer or letters, but it's close and the calculation is much faster
|
||||
// E.g. on 1080p, this results in 440px versus 395px actual, so only minimal scaling down is required
|
||||
(configuration.screenWidthDp.dp - (2 * 16.dp + 3 * spacing))
|
||||
.div(columns)
|
||||
.roundToPx()
|
||||
}
|
||||
}
|
||||
OneTimeLaunchedEffect {
|
||||
viewModel.init(itemId)
|
||||
viewModel.init(cardWidthPx)
|
||||
}
|
||||
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
|
||||
val genres by viewModel.genres.observeAsState(listOf())
|
||||
|
|
@ -214,7 +242,11 @@ fun GenreCardGrid(
|
|||
genre.name,
|
||||
item?.title,
|
||||
).joinToString(" "),
|
||||
filter = GetItemsFilter(genres = listOf(genre.id)),
|
||||
filter =
|
||||
GetItemsFilter(
|
||||
genres = listOf(genre.id),
|
||||
includeItemTypes = includeItemTypes,
|
||||
),
|
||||
useSavedLibraryDisplayInfo = false,
|
||||
),
|
||||
recursive = true,
|
||||
|
|
@ -231,7 +263,8 @@ fun GenreCardGrid(
|
|||
initialPosition = 0,
|
||||
positionCallback = { columns, position ->
|
||||
},
|
||||
columns = 4,
|
||||
columns = columns,
|
||||
spacing = spacing,
|
||||
cardContent = { item: Genre?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier ->
|
||||
GenreCard(
|
||||
genre = item,
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
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, now) {
|
||||
buildList {
|
||||
dto?.productionYear?.let { add(it.toString()) }
|
||||
addRuntimeDetails(context, now, dto)
|
||||
dto?.officialRating?.let(::add)
|
||||
}
|
||||
}
|
||||
|
||||
DotSeparatedRow(
|
||||
texts = details,
|
||||
communityRating = dto?.communityRating,
|
||||
criticRating = dto?.criticRating,
|
||||
textStyle = MaterialTheme.typography.titleSmall,
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
|
@ -21,7 +21,10 @@ import androidx.compose.material.icons.filled.MoreVert
|
|||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.runtime.Composable
|
||||
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.focus.FocusRequester
|
||||
|
|
@ -40,6 +43,7 @@ import androidx.tv.material3.Icon
|
|||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.Trailer
|
||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||
|
|
@ -59,10 +63,12 @@ fun ExpandablePlayButtons(
|
|||
resumePosition: Duration,
|
||||
watched: Boolean,
|
||||
favorite: Boolean,
|
||||
trailers: List<Trailer>?,
|
||||
playOnClick: (position: Duration) -> Unit,
|
||||
watchOnClick: () -> Unit,
|
||||
favoriteOnClick: () -> Unit,
|
||||
moreOnClick: () -> Unit,
|
||||
trailerOnClick: (Trailer) -> Unit,
|
||||
buttonOnFocusChanged: (FocusState) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
|
|
@ -134,6 +140,16 @@ fun ExpandablePlayButtons(
|
|||
)
|
||||
}
|
||||
|
||||
if (trailers != null) {
|
||||
item("trailers") {
|
||||
TrailerButton(
|
||||
trailers = trailers,
|
||||
trailerOnClick = trailerOnClick,
|
||||
modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// More button
|
||||
item("more") {
|
||||
ExpandablePlayButton(
|
||||
|
|
@ -163,10 +179,12 @@ fun ExpandablePlayButton(
|
|||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
mirrorIcon: Boolean = false,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
val isFocused = interactionSource.collectIsFocusedAsState().value
|
||||
Button(
|
||||
onClick = { onClick.invoke(resume) },
|
||||
enabled = enabled,
|
||||
modifier =
|
||||
modifier.requiredSizeIn(
|
||||
minWidth = MinButtonSize,
|
||||
|
|
@ -213,10 +231,12 @@ fun ExpandableFaButton(
|
|||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
iconColor: Color = Color.Unspecified,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
val isFocused = interactionSource.collectIsFocusedAsState().value
|
||||
Button(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier =
|
||||
modifier.requiredSizeIn(
|
||||
minWidth = MinButtonSize,
|
||||
|
|
@ -251,6 +271,42 @@ fun ExpandableFaButton(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TrailerButton(
|
||||
trailers: List<Trailer>,
|
||||
trailerOnClick: (Trailer) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
ExpandableFaButton(
|
||||
title =
|
||||
if (trailers.isEmpty()) {
|
||||
R.string.no_trailers
|
||||
} else if (trailers.size == 1) {
|
||||
R.string.play_trailer
|
||||
} else {
|
||||
R.string.trailers
|
||||
},
|
||||
iconStringRes = R.string.fa_film,
|
||||
enabled = trailers.isNotEmpty(),
|
||||
onClick = {
|
||||
if (trailers.size == 1) {
|
||||
trailerOnClick.invoke(trailers.first())
|
||||
} else {
|
||||
showDialog = true
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
if (showDialog) {
|
||||
TrailerDialog(
|
||||
onDismissRequest = { showDialog = false },
|
||||
trailers = trailers,
|
||||
onClick = trailerOnClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewTvSpec
|
||||
@Composable
|
||||
private fun ExpandablePlayButtonsPreview() {
|
||||
|
|
@ -264,6 +320,8 @@ private fun ExpandablePlayButtonsPreview() {
|
|||
favoriteOnClick = {},
|
||||
moreOnClick = {},
|
||||
buttonOnFocusChanged = {},
|
||||
trailers = listOf(),
|
||||
trailerOnClick = {},
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
package com.github.damontecres.wholphin.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.Placeholder
|
||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.ui.TimeFormatter
|
||||
import com.github.damontecres.wholphin.ui.dot
|
||||
import com.github.damontecres.wholphin.ui.util.LocalClock
|
||||
import kotlin.time.Duration
|
||||
|
||||
@Composable
|
||||
fun QuickDetails(
|
||||
details: AnnotatedString,
|
||||
timeRemaining: Duration?,
|
||||
modifier: Modifier = Modifier,
|
||||
textStyle: TextStyle = MaterialTheme.typography.titleSmall,
|
||||
) {
|
||||
val inlineContentMap =
|
||||
remember(textStyle) {
|
||||
mapOf(
|
||||
"star" to
|
||||
InlineTextContent(
|
||||
Placeholder(
|
||||
textStyle.fontSize,
|
||||
textStyle.fontSize,
|
||||
PlaceholderVerticalAlign.TextCenter,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Star,
|
||||
tint = FilledStarColor,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
},
|
||||
"rotten" to
|
||||
InlineTextContent(
|
||||
Placeholder(
|
||||
textStyle.fontSize,
|
||||
textStyle.fontSize,
|
||||
PlaceholderVerticalAlign.TextCenter,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_rotten_tomatoes_rotten),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
tint = Color.Unspecified,
|
||||
)
|
||||
},
|
||||
"fresh" to
|
||||
InlineTextContent(
|
||||
Placeholder(
|
||||
textStyle.fontSize,
|
||||
textStyle.fontSize,
|
||||
PlaceholderVerticalAlign.TextCenter,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_rotten_tomatoes_fresh),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
tint = Color.Unspecified,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Row(modifier = modifier) {
|
||||
Text(
|
||||
text = details,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = textStyle,
|
||||
inlineContent = inlineContentMap,
|
||||
maxLines = 1,
|
||||
modifier = Modifier,
|
||||
)
|
||||
timeRemaining?.let { TimeRemaining(it, textStyle = textStyle) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TimeRemaining(
|
||||
remaining: Duration,
|
||||
modifier: Modifier = Modifier,
|
||||
textStyle: TextStyle = MaterialTheme.typography.titleSmall,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val now by LocalClock.current.now
|
||||
val remainingStr =
|
||||
remember(remaining, now) {
|
||||
val endTimeStr = TimeFormatter.format(now.plusSeconds(remaining.inWholeSeconds))
|
||||
buildAnnotatedString {
|
||||
dot()
|
||||
append(context.getString(R.string.ends_at, endTimeStr))
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = remainingStr,
|
||||
style = textStyle,
|
||||
maxLines = 1,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
|
@ -12,12 +12,11 @@ import com.github.damontecres.wholphin.data.ServerRepository
|
|||
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.DatePlayedService
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.LatestNextUpService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||
import com.github.damontecres.wholphin.ui.main.buildCombinedNextUp
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.toBaseItems
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
|
|
@ -58,7 +57,7 @@ class RecommendedTvShowViewModel
|
|||
private val api: ApiClient,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val preferencesDataStore: DataStore<AppPreferences>,
|
||||
private val datePlayedService: DatePlayedService,
|
||||
private val lastestNextUpService: LatestNextUpService,
|
||||
@Assisted val parentId: UUID,
|
||||
navigationManager: NavigationManager,
|
||||
favoriteWatchManager: FavoriteWatchManager,
|
||||
|
|
@ -126,9 +125,7 @@ class RecommendedTvShowViewModel
|
|||
val nextUpItems = nextUpItemsDeferred.await()
|
||||
if (combineNextUp) {
|
||||
val combined =
|
||||
buildCombinedNextUp(
|
||||
viewModelScope,
|
||||
datePlayedService,
|
||||
lastestNextUpService.buildCombined(
|
||||
resumeItems,
|
||||
nextUpItems,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
package com.github.damontecres.wholphin.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.LocalContentColor
|
||||
|
||||
@Composable
|
||||
fun BoxScope.SelectedLeadingContent(
|
||||
selected: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (selected) {
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.padding(horizontal = 4.dp)
|
||||
.clip(CircleShape)
|
||||
.align(Alignment.Center)
|
||||
.background(LocalContentColor.current)
|
||||
.size(8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +1,13 @@
|
|||
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.compose.ui.unit.sp
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
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.util.LocalClock
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
|
||||
@Composable
|
||||
fun SeriesName(
|
||||
|
|
@ -41,7 +34,8 @@ fun EpisodeName(
|
|||
text = episodeName ?: "",
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
maxLines = 2,
|
||||
fontSize = 20.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier,
|
||||
)
|
||||
|
|
@ -52,52 +46,3 @@ fun EpisodeName(
|
|||
episode: BaseItemDto?,
|
||||
modifier: Modifier = Modifier,
|
||||
) = EpisodeName(episode?.episodeTitle ?: episode?.name, modifier)
|
||||
|
||||
@Composable
|
||||
fun EpisodeQuickDetails(
|
||||
dto: BaseItemDto?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val now = LocalClock.current.now
|
||||
val details =
|
||||
remember(dto, now) {
|
||||
buildList {
|
||||
dto?.seasonEpisode?.let(::add)
|
||||
dto?.premiereDate?.let { add(formatDateTime(it)) }
|
||||
addRuntimeDetails(context, now, dto)
|
||||
dto?.officialRating?.let(::add)
|
||||
}
|
||||
}
|
||||
DotSeparatedRow(
|
||||
texts = details,
|
||||
communityRating = dto?.communityRating,
|
||||
criticRating = dto?.criticRating,
|
||||
textStyle = MaterialTheme.typography.titleSmall,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SeriesQuickDetails(
|
||||
dto: BaseItemDto?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val details =
|
||||
remember(dto) {
|
||||
buildList {
|
||||
dto?.seriesProductionYears?.let(::add)
|
||||
dto?.runTimeTicks?.ticks?.roundMinutes?.let {
|
||||
add(it.toString())
|
||||
}
|
||||
dto?.officialRating?.let(::add)
|
||||
}
|
||||
}
|
||||
DotSeparatedRow(
|
||||
texts = details,
|
||||
communityRating = dto?.communityRating,
|
||||
criticRating = dto?.criticRating,
|
||||
textStyle = MaterialTheme.typography.titleSmall,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ 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.focusRestorer
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
|
|
@ -39,21 +40,25 @@ import androidx.compose.ui.unit.sp
|
|||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import timber.log.Timber
|
||||
|
||||
@Composable
|
||||
fun TabRow(
|
||||
selectedTabIndex: Int,
|
||||
tabs: List<String>,
|
||||
focusRequesters: List<FocusRequester>,
|
||||
onClick: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val state = rememberLazyListState()
|
||||
LaunchedEffect(selectedTabIndex) {
|
||||
state.animateScrollToItem(selectedTabIndex, -(state.layoutInfo.viewportSize.width / 3.5).toInt())
|
||||
if (selectedTabIndex >= 0) {
|
||||
state.animateScrollToItem(selectedTabIndex, -(state.layoutInfo.viewportSize.width / 3.5).toInt())
|
||||
}
|
||||
}
|
||||
val focusRequesters = remember(tabs) { List(tabs.size) { FocusRequester() } }
|
||||
var rowHasFocus by remember { mutableStateOf(false) }
|
||||
LazyRow(
|
||||
state = state,
|
||||
|
|
@ -66,6 +71,7 @@ fun TabRow(
|
|||
onEnter = {
|
||||
// If entering from left or right, use last or first tab
|
||||
// Otherwise use the selected tab
|
||||
Timber.v("onEnter requestedFocusDirection=$requestedFocusDirection, selectedTabIndex=$selectedTabIndex")
|
||||
val focusRequester =
|
||||
if (requestedFocusDirection == FocusDirection.Left) {
|
||||
focusRequesters.lastOrNull()
|
||||
|
|
@ -181,6 +187,7 @@ private fun TabRowPreview() {
|
|||
TabRow(
|
||||
selectedTabIndex = 1,
|
||||
tabs = listOf("Tab 1", "Tab 2", "Tab 3"),
|
||||
focusRequesters = listOf(),
|
||||
onClick = {},
|
||||
)
|
||||
Tab(
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ 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.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -13,8 +14,9 @@ import com.github.damontecres.wholphin.ui.util.LocalClock
|
|||
|
||||
@Composable
|
||||
fun BoxScope.TimeDisplay(modifier: Modifier = Modifier) {
|
||||
val timeString by LocalClock.current.timeString
|
||||
Text(
|
||||
text = LocalClock.current.timeString,
|
||||
text = timeString,
|
||||
fontSize = 18.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
package com.github.damontecres.wholphin.ui.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.LocalTrailer
|
||||
import com.github.damontecres.wholphin.data.model.RemoteTrailer
|
||||
import com.github.damontecres.wholphin.data.model.Trailer
|
||||
|
||||
@Composable
|
||||
fun TrailerDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
trailers: List<Trailer>,
|
||||
onClick: (Trailer) -> Unit,
|
||||
) {
|
||||
val trailersStr = stringResource(R.string.play_trailer)
|
||||
val localStr = stringResource(R.string.local)
|
||||
val externalStr = stringResource(R.string.external_track)
|
||||
val params =
|
||||
remember(trailers) {
|
||||
DialogParams(
|
||||
fromLongClick = false,
|
||||
title = trailersStr,
|
||||
items =
|
||||
trailers.map { trailer ->
|
||||
DialogItem(
|
||||
headlineContent = {
|
||||
Text(trailer.name)
|
||||
},
|
||||
supportingContent = {
|
||||
val subtitle =
|
||||
when (trailer) {
|
||||
is LocalTrailer -> localStr
|
||||
is RemoteTrailer -> trailer.subtitle ?: externalStr
|
||||
}
|
||||
Text(
|
||||
text = subtitle,
|
||||
)
|
||||
},
|
||||
onClick = { onClick.invoke(trailer) },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
DialogPopup(
|
||||
params = params,
|
||||
onDismissRequest = onDismissRequest,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
package com.github.damontecres.wholphin.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
|
|
@ -31,28 +30,31 @@ import com.github.damontecres.wholphin.data.ChosenStreams
|
|||
import com.github.damontecres.wholphin.preferences.AppThemeColors
|
||||
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.ui.util.StreamFormatting.concatWithSpace
|
||||
import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatAudioCodec
|
||||
import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatSubtitleCodec
|
||||
import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatVideoRange
|
||||
import com.github.damontecres.wholphin.ui.util.StreamFormatting.resolutionString
|
||||
import com.github.damontecres.wholphin.util.languageName
|
||||
import com.github.damontecres.wholphin.util.profile.Codec
|
||||
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(
|
||||
chosenStreams: ChosenStreams?,
|
||||
numberOfVersions: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
) = VideoStreamDetails(
|
||||
chosenStreams?.source,
|
||||
chosenStreams?.videoStream,
|
||||
chosenStreams?.audioStream,
|
||||
chosenStreams?.subtitleStream,
|
||||
numberOfVersions,
|
||||
modifier,
|
||||
)
|
||||
|
||||
|
|
@ -62,6 +64,7 @@ fun VideoStreamDetails(
|
|||
videoStream: MediaStream?,
|
||||
audioStream: MediaStream?,
|
||||
subtitleStream: MediaStream?,
|
||||
numberOfVersions: Int = 0,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
|
@ -86,13 +89,16 @@ fun VideoStreamDetails(
|
|||
null
|
||||
}
|
||||
val range = formatVideoRange(context, it.videoRange, it.videoRangeType, it.videoDoViTitle)
|
||||
listOfNotNull(
|
||||
resName.concatWithSpace(range),
|
||||
it.codec?.uppercase(),
|
||||
)
|
||||
}.orEmpty()
|
||||
resName.concatWithSpace(range)
|
||||
}
|
||||
}
|
||||
video.forEach {
|
||||
video?.let {
|
||||
StreamLabel(
|
||||
text = it,
|
||||
count = numberOfVersions,
|
||||
)
|
||||
}
|
||||
videoStream?.codec?.uppercase()?.let {
|
||||
StreamLabel(it)
|
||||
}
|
||||
|
||||
|
|
@ -149,35 +155,6 @@ fun VideoStreamDetails(
|
|||
}
|
||||
}
|
||||
|
||||
fun interlaced(interlaced: Boolean) = if (interlaced) "i" else "p"
|
||||
|
||||
// Adapted from https://github.com/jellyfin/jellyfin/blob/aa4ddd139a7c01889a99561fc314121ba198dd70/MediaBrowser.Model/Entities/MediaStream.cs#L714
|
||||
fun resolutionString(
|
||||
width: Int,
|
||||
height: Int,
|
||||
interlaced: Boolean,
|
||||
): String =
|
||||
if (height > width) {
|
||||
// Vertical video
|
||||
resolutionString(height, width, interlaced)
|
||||
} else {
|
||||
when {
|
||||
width <= 256 && height <= 144 -> "144" + interlaced(interlaced)
|
||||
width <= 426 && height <= 240 -> "240" + interlaced(interlaced)
|
||||
width <= 640 && height <= 360 -> "360" + interlaced(interlaced)
|
||||
width <= 682 && height <= 384 -> "384" + interlaced(interlaced)
|
||||
width <= 720 && height <= 404 -> "404" + interlaced(interlaced)
|
||||
width <= 854 && height <= 480 -> "480" + interlaced(interlaced)
|
||||
width <= 960 && height <= 544 -> "540" + interlaced(interlaced)
|
||||
width <= 1024 && height <= 576 -> "576" + interlaced(interlaced)
|
||||
width <= 1280 && height <= 962 -> "720" + interlaced(interlaced)
|
||||
width <= 2560 && height <= 1440 -> "1080" + interlaced(interlaced)
|
||||
width <= 4096 && height <= 3072 -> "4K"
|
||||
width <= 8192 && height <= 6144 -> "8K"
|
||||
else -> height.toString() + interlaced(interlaced)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StreamLabel(
|
||||
text: String,
|
||||
|
|
@ -251,93 +228,3 @@ private fun StreamLabelPreview() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun formatVideoRange(
|
||||
context: Context,
|
||||
videoRange: VideoRange?,
|
||||
type: VideoRangeType?,
|
||||
doviTitle: String?,
|
||||
): String? =
|
||||
when (videoRange) {
|
||||
VideoRange.UNKNOWN,
|
||||
VideoRange.SDR, null,
|
||||
-> {
|
||||
null
|
||||
}
|
||||
|
||||
VideoRange.HDR -> {
|
||||
if (doviTitle.isNotNullOrBlank()) {
|
||||
context.getString(R.string.dolby_vision)
|
||||
} else {
|
||||
when (type) {
|
||||
VideoRangeType.UNKNOWN,
|
||||
VideoRangeType.SDR,
|
||||
null,
|
||||
-> null
|
||||
|
||||
VideoRangeType.HDR10 -> "HDR10"
|
||||
|
||||
VideoRangeType.HDR10_PLUS -> "HDR10+"
|
||||
|
||||
VideoRangeType.HLG -> "HLG"
|
||||
|
||||
VideoRangeType.DOVI,
|
||||
VideoRangeType.DOVI_WITH_HDR10,
|
||||
VideoRangeType.DOVI_WITH_HLG,
|
||||
VideoRangeType.DOVI_WITH_SDR,
|
||||
-> context.getString(R.string.dolby_vision)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun formatAudioCodec(
|
||||
context: Context,
|
||||
codec: String?,
|
||||
profile: String?,
|
||||
): String? =
|
||||
when {
|
||||
profile?.contains("Dolby Atmos", true) == true -> {
|
||||
context.getString(R.string.dolby_atmos)
|
||||
}
|
||||
|
||||
profile?.contains("DTS:X", true) == true -> {
|
||||
"DTS:X"
|
||||
}
|
||||
|
||||
profile?.contains("DTS:HD", true) == true -> {
|
||||
"DTS:HD"
|
||||
}
|
||||
|
||||
else -> {
|
||||
when (codec?.lowercase()) {
|
||||
Codec.Audio.TRUEHD -> "TrueHD"
|
||||
|
||||
Codec.Audio.OGG,
|
||||
Codec.Audio.OPUS,
|
||||
Codec.Audio.VORBIS,
|
||||
-> codec.replaceFirstChar { it.uppercase() }
|
||||
|
||||
null -> null
|
||||
|
||||
else -> codec.uppercase()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun formatSubtitleCodec(codec: String?): String? =
|
||||
when (codec?.lowercase()) {
|
||||
Codec.Subtitle.DVBSUB -> "DVB"
|
||||
Codec.Subtitle.DVDSUB -> "DVD"
|
||||
Codec.Subtitle.PGSSUB -> "PGS"
|
||||
Codec.Subtitle.SUBRIP -> "SRT"
|
||||
null -> null
|
||||
else -> codec.uppercase()
|
||||
}
|
||||
|
||||
fun String?.concatWithSpace(str: String?): String? =
|
||||
when {
|
||||
this != null && str != null -> "$this $str"
|
||||
this == null -> str
|
||||
else -> this
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,442 @@
|
|||
package com.github.damontecres.wholphin.ui.components
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.speech.RecognitionListener
|
||||
import android.speech.RecognizerIntent
|
||||
import android.speech.SpeechRecognizer
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.github.damontecres.wholphin.R
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.android.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import timber.log.Timber
|
||||
import java.io.Closeable
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val RMS_DB_MIN = -2.0f
|
||||
private const val RMS_DB_MAX = 10.0f
|
||||
private const val MAX_RESULTS = 1
|
||||
private const val LISTENING_TIMEOUT_MS = 8000L
|
||||
private const val RECOGNIZER_RECREATE_DELAY_MS = 150L
|
||||
|
||||
private val ERROR_TO_RESOURCE_MAP =
|
||||
mapOf(
|
||||
SpeechRecognizer.ERROR_AUDIO to R.string.voice_error_audio,
|
||||
SpeechRecognizer.ERROR_CLIENT to R.string.voice_error_client,
|
||||
SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS to R.string.voice_error_permissions,
|
||||
SpeechRecognizer.ERROR_NETWORK to R.string.voice_error_network,
|
||||
SpeechRecognizer.ERROR_NETWORK_TIMEOUT to R.string.voice_error_network_timeout,
|
||||
SpeechRecognizer.ERROR_NO_MATCH to R.string.voice_error_no_match,
|
||||
SpeechRecognizer.ERROR_RECOGNIZER_BUSY to R.string.voice_error_busy,
|
||||
SpeechRecognizer.ERROR_SERVER to R.string.voice_error_server,
|
||||
SpeechRecognizer.ERROR_SPEECH_TIMEOUT to R.string.voice_error_speech_timeout,
|
||||
SpeechRecognizer.ERROR_TOO_MANY_REQUESTS to R.string.voice_error_busy,
|
||||
)
|
||||
|
||||
private val RETRYABLE_ERRORS =
|
||||
setOf(
|
||||
SpeechRecognizer.ERROR_NETWORK,
|
||||
SpeechRecognizer.ERROR_NETWORK_TIMEOUT,
|
||||
SpeechRecognizer.ERROR_SERVER,
|
||||
SpeechRecognizer.ERROR_NO_MATCH,
|
||||
SpeechRecognizer.ERROR_SPEECH_TIMEOUT,
|
||||
)
|
||||
|
||||
private fun normalizeRmsDb(rmsdB: Float) = ((rmsdB - RMS_DB_MIN) / (RMS_DB_MAX - RMS_DB_MIN)).coerceIn(0f, 1f)
|
||||
|
||||
sealed interface VoiceInputState {
|
||||
data object Idle : VoiceInputState
|
||||
|
||||
/** Recognizer is being initialized, not yet ready for speech. */
|
||||
data object Starting : VoiceInputState
|
||||
|
||||
data object Listening : VoiceInputState
|
||||
|
||||
data object Processing : VoiceInputState
|
||||
|
||||
data class Result(
|
||||
val text: String,
|
||||
) : VoiceInputState
|
||||
|
||||
data class Error(
|
||||
val messageResId: Int,
|
||||
val isRetryable: Boolean,
|
||||
) : VoiceInputState
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class VoiceInputManager
|
||||
@Inject
|
||||
constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : Closeable {
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val mainDispatcher = provideMainDispatcher()
|
||||
private val scope = CoroutineScope(mainDispatcher + SupervisorJob())
|
||||
private val mutex = Mutex()
|
||||
|
||||
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
private val connectivityManager =
|
||||
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
|
||||
private val audioFocusListener =
|
||||
AudioManager.OnAudioFocusChangeListener { focusChange ->
|
||||
when (focusChange) {
|
||||
AudioManager.AUDIOFOCUS_LOSS -> {
|
||||
Timber.d("Permanent audio focus loss. Stopping listening.")
|
||||
stopListening()
|
||||
}
|
||||
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
||||
Timber.d("Transient audio focus loss. Ignoring to allow SpeechRecognizer to work.")
|
||||
}
|
||||
|
||||
else -> {
|
||||
Timber.d("Audio focus change: $focusChange")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val audioFocusRequest: AudioFocusRequest? by lazy {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
AudioFocusRequest
|
||||
.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE)
|
||||
.setOnAudioFocusChangeListener(audioFocusListener, handler)
|
||||
.build()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun requestAudioFocusCompat(): Int =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
audioFocusRequest?.let { audioManager.requestAudioFocus(it) }
|
||||
?: AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||
} else {
|
||||
audioManager.requestAudioFocus(
|
||||
audioFocusListener,
|
||||
AudioManager.STREAM_MUSIC,
|
||||
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun abandonAudioFocusCompat() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) }
|
||||
} else {
|
||||
audioManager.abandonAudioFocus(audioFocusListener)
|
||||
}
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow<VoiceInputState>(VoiceInputState.Idle)
|
||||
val state: StateFlow<VoiceInputState> = _state.asStateFlow()
|
||||
|
||||
private val _soundLevel = MutableStateFlow(0f)
|
||||
val soundLevel: StateFlow<Float> = _soundLevel.asStateFlow()
|
||||
|
||||
private val _partialResult = MutableStateFlow("")
|
||||
val partialResult: StateFlow<String> = _partialResult.asStateFlow()
|
||||
|
||||
val isAvailable = SpeechRecognizer.isRecognitionAvailable(context)
|
||||
val hasPermission: Boolean
|
||||
get() =
|
||||
ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.RECORD_AUDIO,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
private var recognizer: SpeechRecognizer? = null
|
||||
private var timeoutJob: Job? = null
|
||||
|
||||
private fun provideMainDispatcher(): CoroutineDispatcher =
|
||||
try {
|
||||
Dispatchers.Main.immediate
|
||||
} catch (_: IllegalStateException) {
|
||||
// Fallback for unit tests where Main dispatcher is not installed
|
||||
handler.asCoroutineDispatcher()
|
||||
}
|
||||
|
||||
private val recognitionIntent by lazy {
|
||||
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
||||
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
|
||||
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
|
||||
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, MAX_RESULTS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isNetworkAvailable(): Boolean {
|
||||
val network = connectivityManager.activeNetwork ?: return false
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
|
||||
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
}
|
||||
|
||||
fun startListening() {
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
val currentState = _state.value
|
||||
if (currentState is VoiceInputState.Starting || currentState is VoiceInputState.Listening) {
|
||||
return@withLock
|
||||
}
|
||||
|
||||
val hadRecognizer = recognizer != null
|
||||
if (hadRecognizer) {
|
||||
destroyRecognizer()
|
||||
}
|
||||
|
||||
if (!isNetworkAvailable()) {
|
||||
handler.post {
|
||||
_state.value =
|
||||
VoiceInputState.Error(
|
||||
messageResId = R.string.voice_error_network,
|
||||
isRetryable = true,
|
||||
)
|
||||
}
|
||||
return@withLock
|
||||
}
|
||||
|
||||
val focusResult = requestAudioFocusCompat()
|
||||
if (focusResult != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
|
||||
handler.post {
|
||||
_state.value =
|
||||
VoiceInputState.Error(
|
||||
messageResId = R.string.voice_error_audio,
|
||||
isRetryable = true,
|
||||
)
|
||||
}
|
||||
return@withLock
|
||||
}
|
||||
|
||||
cancelTimeout()
|
||||
handler.post {
|
||||
_partialResult.value = ""
|
||||
_soundLevel.value = 0f
|
||||
_state.value = VoiceInputState.Starting
|
||||
}
|
||||
|
||||
// Give the OS time to release the mic before recreating when replacing an old recognizer
|
||||
if (hadRecognizer) {
|
||||
delay(RECOGNIZER_RECREATE_DELAY_MS)
|
||||
}
|
||||
|
||||
val newRecognizer = SpeechRecognizer.createSpeechRecognizer(context)
|
||||
recognizer = newRecognizer
|
||||
newRecognizer.setRecognitionListener(createRecognitionListener(newRecognizer))
|
||||
|
||||
try {
|
||||
newRecognizer.startListening(recognitionIntent)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to start speech recognition")
|
||||
destroyRecognizer()
|
||||
cancelTimeout()
|
||||
handler.post {
|
||||
_state.value =
|
||||
VoiceInputState.Error(
|
||||
messageResId = R.string.voice_error_start_failed,
|
||||
isRetryable = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stopListening() {
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
cancelTimeout()
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelTimeout() {
|
||||
timeoutJob?.cancel()
|
||||
timeoutJob = null
|
||||
}
|
||||
|
||||
private fun startTimeout() {
|
||||
cancelTimeout()
|
||||
timeoutJob =
|
||||
scope.launch {
|
||||
delay(LISTENING_TIMEOUT_MS)
|
||||
mutex.withLock {
|
||||
if (_state.value is VoiceInputState.Listening && recognizer != null) {
|
||||
val partial = _partialResult.value
|
||||
destroyRecognizer()
|
||||
handler.post {
|
||||
_soundLevel.value = 0f
|
||||
_partialResult.value = ""
|
||||
_state.value =
|
||||
if (partial.isNotBlank()) {
|
||||
VoiceInputState.Result(partial)
|
||||
} else {
|
||||
VoiceInputState.Error(
|
||||
messageResId = R.string.voice_error_timeout,
|
||||
isRetryable = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun acknowledge() {
|
||||
handler.post { _state.value = VoiceInputState.Idle }
|
||||
}
|
||||
|
||||
fun onPermissionGranted() = startListening()
|
||||
|
||||
fun onPermissionDenied() {
|
||||
Timber.w("RECORD_AUDIO permission denied")
|
||||
handler.post {
|
||||
_state.value =
|
||||
VoiceInputState.Error(
|
||||
messageResId = R.string.voice_error_permissions,
|
||||
isRetryable = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun destroyRecognizer() {
|
||||
abandonAudioFocusCompat()
|
||||
// Null out FIRST to invalidate callbacks before cancel() can trigger them
|
||||
val rec = recognizer
|
||||
recognizer = null
|
||||
rec?.let {
|
||||
try {
|
||||
it.cancel()
|
||||
it.destroy()
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Error destroying speech recognizer")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
destroyRecognizer()
|
||||
handler.post {
|
||||
_soundLevel.value = 0f
|
||||
_partialResult.value = ""
|
||||
_state.value = VoiceInputState.Idle
|
||||
}
|
||||
}
|
||||
|
||||
private fun createRecognitionListener(activeRecognizer: SpeechRecognizer) =
|
||||
object : RecognitionListener {
|
||||
// Guard against callbacks from zombie recognizers
|
||||
private fun isValid() = recognizer === activeRecognizer
|
||||
|
||||
override fun onReadyForSpeech(params: Bundle?) {
|
||||
if (!isValid()) return
|
||||
handler.post { _state.value = VoiceInputState.Listening }
|
||||
startTimeout()
|
||||
}
|
||||
|
||||
override fun onBeginningOfSpeech() {
|
||||
if (!isValid()) return
|
||||
}
|
||||
|
||||
override fun onRmsChanged(rmsdB: Float) {
|
||||
if (!isValid()) return
|
||||
handler.post { _soundLevel.value = normalizeRmsDb(rmsdB) }
|
||||
}
|
||||
|
||||
override fun onBufferReceived(buffer: ByteArray?) = Unit
|
||||
|
||||
override fun onEndOfSpeech() {
|
||||
if (!isValid()) return
|
||||
cancelTimeout()
|
||||
handler.post { _state.value = VoiceInputState.Processing }
|
||||
}
|
||||
|
||||
override fun onError(error: Int) {
|
||||
if (!isValid()) return
|
||||
Timber.e("Voice recognition error code: $error")
|
||||
cancelTimeout()
|
||||
destroyRecognizer()
|
||||
|
||||
if (error == SpeechRecognizer.ERROR_TOO_MANY_REQUESTS) {
|
||||
handler.post {
|
||||
_state.value =
|
||||
VoiceInputState.Error(
|
||||
messageResId = ERROR_TO_RESOURCE_MAP[error] ?: R.string.voice_error_unknown,
|
||||
isRetryable = false,
|
||||
)
|
||||
_soundLevel.value = 0f
|
||||
_partialResult.value = ""
|
||||
}
|
||||
return
|
||||
}
|
||||
handler.post {
|
||||
_state.value =
|
||||
VoiceInputState.Error(
|
||||
messageResId = ERROR_TO_RESOURCE_MAP[error] ?: R.string.voice_error_unknown,
|
||||
isRetryable = error in RETRYABLE_ERRORS,
|
||||
)
|
||||
_soundLevel.value = 0f
|
||||
_partialResult.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResults(results: Bundle?) {
|
||||
if (!isValid()) return
|
||||
cancelTimeout()
|
||||
val spokenText =
|
||||
results
|
||||
?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
|
||||
?.firstOrNull()
|
||||
handler.post {
|
||||
_state.value =
|
||||
if (!spokenText.isNullOrBlank()) {
|
||||
VoiceInputState.Result(spokenText)
|
||||
} else {
|
||||
VoiceInputState.Error(
|
||||
messageResId = R.string.voice_error_no_match,
|
||||
isRetryable = true,
|
||||
)
|
||||
}
|
||||
_soundLevel.value = 0f
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPartialResults(partialResults: Bundle?) {
|
||||
if (!isValid()) return
|
||||
partialResults
|
||||
?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
|
||||
?.firstOrNull()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { handler.post { _partialResult.value = it } }
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
eventType: Int,
|
||||
params: Bundle?,
|
||||
) = Unit
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,414 @@
|
|||
package com.github.damontecres.wholphin.ui.components
|
||||
|
||||
import android.Manifest
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
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.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.requiredSizeIn
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
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.remember
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.tv.material3.ButtonDefaults
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.OutlinedButton
|
||||
import androidx.tv.material3.OutlinedButtonDefaults
|
||||
import androidx.tv.material3.Text
|
||||
import androidx.tv.material3.surfaceColorAtElevation
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
private const val ERROR_AUTO_DISMISS_DELAY_MS = 3000L
|
||||
private const val SOUND_LEVEL_SCALE_FACTOR = 0.15f
|
||||
private val BUBBLE_SIZE = 160.dp
|
||||
private val MIC_ICON_FONT_SIZE = 56.sp
|
||||
private val BUTTON_ICON_FONT_SIZE = 20.sp
|
||||
private val CONTENT_SPACING = 48.dp
|
||||
private val HORIZONTAL_PADDING = 64.dp
|
||||
private val DISMISS_HINT_BOTTOM_PADDING = 32.dp
|
||||
private const val HINT_TEXT_ALPHA = 0.5f
|
||||
private const val SOUND_LEVEL_ANIM_MS = 100
|
||||
private const val BASE_PULSE_ANIM_MS = 800
|
||||
private const val RIPPLE_ANIM_MS = 1500
|
||||
private const val DOTS_ANIM_MS = 1200
|
||||
private const val RIPPLE_CANVAS_SCALE = 1.8f
|
||||
private const val MAX_RIPPLE_EXPANSION = 0.35f
|
||||
private val RIPPLE_STROKE_WIDTH = 2.dp
|
||||
private const val RIPPLE_MAX_ALPHA = 0.4f
|
||||
|
||||
private fun VoiceInputState.shouldShowOverlay() =
|
||||
this is VoiceInputState.Starting ||
|
||||
this is VoiceInputState.Listening ||
|
||||
this is VoiceInputState.Processing ||
|
||||
this is VoiceInputState.Error
|
||||
|
||||
@Composable
|
||||
fun VoiceSearchButton(
|
||||
onSpeechResult: (String) -> Unit,
|
||||
voiceInputManager: VoiceInputManager?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (voiceInputManager == null || !voiceInputManager.isAvailable) return
|
||||
|
||||
val state by voiceInputManager.state.collectAsState()
|
||||
val soundLevel by voiceInputManager.soundLevel.collectAsState()
|
||||
val partialResult by voiceInputManager.partialResult.collectAsState()
|
||||
|
||||
LaunchedEffect(state) {
|
||||
val currentState = state
|
||||
when (currentState) {
|
||||
is VoiceInputState.Result -> {
|
||||
onSpeechResult(currentState.text)
|
||||
// Small delay to allow focus to be restored before dialog dismisses
|
||||
delay(50)
|
||||
voiceInputManager.acknowledge()
|
||||
}
|
||||
|
||||
is VoiceInputState.Error -> {
|
||||
if (!currentState.isRetryable) {
|
||||
delay(ERROR_AUTO_DISMISS_DELAY_MS)
|
||||
voiceInputManager.acknowledge()
|
||||
}
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
val permissionLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { isGranted ->
|
||||
if (isGranted) {
|
||||
voiceInputManager.onPermissionGranted()
|
||||
} else {
|
||||
voiceInputManager.onPermissionDenied()
|
||||
}
|
||||
}
|
||||
|
||||
if (state.shouldShowOverlay()) {
|
||||
val errorState = state as? VoiceInputState.Error
|
||||
val errorMessage = errorState?.messageResId?.let { stringResource(it) }
|
||||
VoiceSearchOverlay(
|
||||
soundLevel = soundLevel,
|
||||
partialResult = partialResult,
|
||||
isStarting = state is VoiceInputState.Starting,
|
||||
isProcessing = state is VoiceInputState.Processing,
|
||||
errorMessage = errorMessage,
|
||||
isRetryable = errorState?.isRetryable == true,
|
||||
onRetry = { voiceInputManager.startListening() },
|
||||
onDismiss = { voiceInputManager.stopListening() },
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
when (state) {
|
||||
is VoiceInputState.Starting,
|
||||
is VoiceInputState.Listening,
|
||||
-> {
|
||||
voiceInputManager.stopListening()
|
||||
}
|
||||
|
||||
else -> {
|
||||
if (voiceInputManager.hasPermission) {
|
||||
voiceInputManager.startListening()
|
||||
} else {
|
||||
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier =
|
||||
modifier.requiredSizeIn(
|
||||
minWidth = MinButtonSize,
|
||||
minHeight = MinButtonSize,
|
||||
maxWidth = MinButtonSize,
|
||||
maxHeight = MinButtonSize,
|
||||
),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val voiceSearchDesc = stringResource(R.string.voice_search)
|
||||
Text(
|
||||
text = stringResource(R.string.fa_microphone),
|
||||
fontFamily = FontAwesome,
|
||||
fontSize = BUTTON_ICON_FONT_SIZE,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.semantics { contentDescription = voiceSearchDesc },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceRippleRings(
|
||||
rippleProgress: Float,
|
||||
bubbleSize: androidx.compose.ui.unit.Dp,
|
||||
color: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val rippleStroke =
|
||||
remember(density) {
|
||||
Stroke(width = with(density) { RIPPLE_STROKE_WIDTH.toPx() })
|
||||
}
|
||||
|
||||
Canvas(modifier = modifier.size(bubbleSize * RIPPLE_CANVAS_SCALE)) {
|
||||
val canvasCenter = center
|
||||
val baseRadius = bubbleSize.toPx() / 2
|
||||
val maxExpansion = bubbleSize.toPx() * MAX_RIPPLE_EXPANSION
|
||||
|
||||
for (i in 0..2) {
|
||||
val ringProgress = (rippleProgress + (i * 0.33f)) % 1f
|
||||
val ringRadius = baseRadius + (ringProgress * maxExpansion)
|
||||
val ringAlpha = (1f - ringProgress) * RIPPLE_MAX_ALPHA
|
||||
drawCircle(
|
||||
color = color.copy(alpha = ringAlpha),
|
||||
radius = ringRadius,
|
||||
center = canvasCenter,
|
||||
style = rippleStroke,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getStatusText(
|
||||
errorMessage: String?,
|
||||
partialResult: String,
|
||||
isStarting: Boolean,
|
||||
isProcessing: Boolean,
|
||||
startingText: String,
|
||||
processingText: String,
|
||||
listeningText: String,
|
||||
dotCount: Int,
|
||||
): Pair<String, String> {
|
||||
val dots = ".".repeat(dotCount)
|
||||
return when {
|
||||
errorMessage != null -> errorMessage to errorMessage
|
||||
isProcessing -> (processingText + dots) to processingText
|
||||
isStarting -> (startingText + dots) to startingText
|
||||
partialResult.isNotBlank() -> partialResult to partialResult
|
||||
else -> (listeningText + dots) to listeningText
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceSearchOverlay(
|
||||
soundLevel: Float,
|
||||
partialResult: String,
|
||||
isStarting: Boolean,
|
||||
isProcessing: Boolean,
|
||||
errorMessage: String?,
|
||||
isRetryable: Boolean,
|
||||
onRetry: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val primaryColor = MaterialTheme.colorScheme.primary
|
||||
val onPrimaryColor = MaterialTheme.colorScheme.onPrimary
|
||||
val errorColor = MaterialTheme.colorScheme.error
|
||||
|
||||
val animatedSoundLevel by animateFloatAsState(
|
||||
targetValue = soundLevel,
|
||||
animationSpec = tween(durationMillis = SOUND_LEVEL_ANIM_MS),
|
||||
label = "soundLevel",
|
||||
)
|
||||
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
|
||||
val basePulse by infiniteTransition.animateFloat(
|
||||
initialValue = 1f,
|
||||
targetValue = 1.05f,
|
||||
animationSpec =
|
||||
infiniteRepeatable(
|
||||
animation = tween(durationMillis = BASE_PULSE_ANIM_MS),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "basePulse",
|
||||
)
|
||||
|
||||
// Only animate ripples when actively listening (not starting, processing, or in error)
|
||||
val shouldAnimateRipples = !isStarting && !isProcessing && errorMessage == null
|
||||
val rippleProgress by infiniteTransition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = if (shouldAnimateRipples) 1f else 0f,
|
||||
animationSpec =
|
||||
infiniteRepeatable(
|
||||
animation = tween(durationMillis = RIPPLE_ANIM_MS),
|
||||
repeatMode = RepeatMode.Restart,
|
||||
),
|
||||
label = "ripple",
|
||||
)
|
||||
|
||||
val dotAnimation by infiniteTransition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 4f,
|
||||
animationSpec =
|
||||
infiniteRepeatable(
|
||||
animation = tween(durationMillis = DOTS_ANIM_MS),
|
||||
repeatMode = RepeatMode.Restart,
|
||||
),
|
||||
label = "dots",
|
||||
)
|
||||
|
||||
val bubbleScale = basePulse + (animatedSoundLevel * SOUND_LEVEL_SCALE_FACTOR)
|
||||
|
||||
val statusFocusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
statusFocusRequester.requestFocus()
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties =
|
||||
DialogProperties(
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = true,
|
||||
usePlatformDefaultWidth = false,
|
||||
),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(0.85f)
|
||||
.wrapContentHeight()
|
||||
.padding(vertical = 48.dp)
|
||||
.clip(MaterialTheme.shapes.large)
|
||||
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(CONTENT_SPACING),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = HORIZONTAL_PADDING),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
val rippleAlpha = if (shouldAnimateRipples) 1f else 0f
|
||||
Box(modifier = Modifier.graphicsLayer { alpha = rippleAlpha }) {
|
||||
VoiceRippleRings(
|
||||
rippleProgress = rippleProgress,
|
||||
bubbleSize = BUBBLE_SIZE,
|
||||
color = primaryColor,
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(BUBBLE_SIZE)
|
||||
.graphicsLayer {
|
||||
scaleX = bubbleScale
|
||||
scaleY = bubbleScale
|
||||
}.clip(CircleShape)
|
||||
.background(if (errorMessage != null) errorColor else primaryColor),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val voiceSearchDesc = stringResource(R.string.voice_search)
|
||||
Text(
|
||||
text = stringResource(R.string.fa_microphone),
|
||||
fontFamily = FontAwesome,
|
||||
fontSize = MIC_ICON_FONT_SIZE,
|
||||
color = onPrimaryColor,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.semantics { contentDescription = voiceSearchDesc },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val startingText = stringResource(R.string.voice_starting)
|
||||
val processingText = stringResource(R.string.processing)
|
||||
val listeningText = stringResource(R.string.voice_search_prompt)
|
||||
val (statusText, accessibilityDescription) =
|
||||
getStatusText(
|
||||
errorMessage = errorMessage,
|
||||
partialResult = partialResult,
|
||||
isStarting = isStarting,
|
||||
isProcessing = isProcessing,
|
||||
startingText = startingText,
|
||||
processingText = processingText,
|
||||
listeningText = listeningText,
|
||||
dotCount = dotAnimation.toInt(),
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f).focusRequester(statusFocusRequester),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = statusText,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = if (errorMessage != null) errorColor else Color.White,
|
||||
modifier = Modifier.semantics { contentDescription = accessibilityDescription },
|
||||
)
|
||||
|
||||
if (errorMessage != null && isRetryable) {
|
||||
OutlinedButton(
|
||||
onClick = onRetry,
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
shape = ButtonDefaults.shape(RoundedCornerShape(50)),
|
||||
colors =
|
||||
OutlinedButtonDefaults.colors(
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.retry),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.press_back_to_cancel),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = HINT_TEXT_ALPHA),
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = DISMISS_HINT_BOTTOM_PADDING),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,8 +8,10 @@ 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.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
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.res.stringResource
|
||||
|
|
@ -19,17 +21,18 @@ 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.ui.util.StreamFormatting.formatAudioCodec
|
||||
import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatSubtitleCodec
|
||||
import com.github.damontecres.wholphin.util.languageName
|
||||
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 org.jellyfin.sdk.model.extensions.ticks
|
||||
import java.util.Locale
|
||||
|
||||
data class ItemDetailsDialogInfo(
|
||||
|
|
@ -54,33 +57,32 @@ fun ItemDetailsDialog(
|
|||
val subtitleLabel = stringResource(R.string.subtitle)
|
||||
val bitrateLabel = stringResource(R.string.bitrate)
|
||||
val unknown = stringResource(R.string.unknown)
|
||||
val runtimeLabel = stringResource(R.string.runtime_sort)
|
||||
|
||||
ScrollableDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
width = 720.dp,
|
||||
maxHeight = 480.dp,
|
||||
itemSpacing = 4.dp,
|
||||
width = 680.dp,
|
||||
maxHeight = 440.dp,
|
||||
itemSpacing = 8.dp,
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = info.title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
}
|
||||
if (info.genres.isNotEmpty()) {
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text(
|
||||
text = info.genres.joinToString(", "),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (info.overview.isNotNullOrBlank()) {
|
||||
item {
|
||||
Text(
|
||||
text = info.overview,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
text = info.title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
if (info.genres.isNotEmpty()) {
|
||||
Text(
|
||||
text = info.genres.joinToString(", "),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
}
|
||||
if (info.overview.isNotNullOrBlank()) {
|
||||
Text(
|
||||
text = info.overview,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -89,13 +91,19 @@ fun ItemDetailsDialog(
|
|||
source.mediaStreams?.letNotEmpty { mediaStreams ->
|
||||
item {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
// General file information
|
||||
item {
|
||||
val containerLabel = stringResource(R.string.container)
|
||||
MediaInfoSection(
|
||||
title = stringResource(R.string.general),
|
||||
title =
|
||||
titleIndex(
|
||||
stringResource(R.string.general),
|
||||
index,
|
||||
info.files.size,
|
||||
),
|
||||
items =
|
||||
buildList {
|
||||
source.container?.let { add(containerLabel to it) }
|
||||
|
|
@ -111,31 +119,38 @@ fun ItemDetailsDialog(
|
|||
bitrateLabel to formatBytes(it, byteRateSuffixes),
|
||||
)
|
||||
}
|
||||
source.runTimeTicks?.let {
|
||||
add(runtimeLabel to it.ticks.toString())
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Video streams
|
||||
items(mediaStreams.filter { it.type == MediaStreamType.VIDEO }) { stream ->
|
||||
val videoStreams = mediaStreams.filter { it.type == MediaStreamType.VIDEO }
|
||||
itemsIndexed(videoStreams) { index, stream ->
|
||||
MediaInfoSection(
|
||||
title = videoLabel,
|
||||
items = buildVideoStreamInfo(context, stream),
|
||||
title = titleIndex(videoLabel, index, videoStreams.size),
|
||||
items = remember { buildVideoStreamInfo(context, stream) },
|
||||
additional = remember { buildVideoStreamInfoAdditional(context, stream) },
|
||||
)
|
||||
}
|
||||
|
||||
// Audio streams - display multiple per row
|
||||
items(
|
||||
mediaStreams
|
||||
.filter { it.type == MediaStreamType.AUDIO }
|
||||
.chunked(3),
|
||||
) { streamGroup ->
|
||||
val audioStreams = mediaStreams.filter { it.type == MediaStreamType.AUDIO }
|
||||
itemsIndexed(audioStreams.chunked(3)) { groupIndex, streamGroup ->
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
streamGroup.forEach { stream ->
|
||||
streamGroup.forEachIndexed { index, stream ->
|
||||
MediaInfoSection(
|
||||
title = audioLabel,
|
||||
title =
|
||||
titleIndex(
|
||||
audioLabel,
|
||||
groupIndex * 3 + index,
|
||||
audioStreams.size,
|
||||
),
|
||||
items = buildAudioStreamInfo(context, stream),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
|
@ -148,19 +163,21 @@ fun ItemDetailsDialog(
|
|||
}
|
||||
|
||||
// Subtitle streams - display multiple per row
|
||||
items(
|
||||
mediaStreams
|
||||
.filter { it.type == MediaStreamType.SUBTITLE }
|
||||
.chunked(3),
|
||||
) { streamGroup ->
|
||||
val subtitleStreams = mediaStreams.filter { it.type == MediaStreamType.SUBTITLE }
|
||||
itemsIndexed(subtitleStreams.chunked(3)) { groupIndex, streamGroup ->
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
streamGroup.forEach { stream ->
|
||||
streamGroup.forEachIndexed { index, stream ->
|
||||
MediaInfoSection(
|
||||
title = subtitleLabel,
|
||||
items = buildSubtitleStreamInfo(context, stream),
|
||||
title =
|
||||
titleIndex(
|
||||
subtitleLabel,
|
||||
groupIndex * 3 + index,
|
||||
subtitleStreams.size,
|
||||
),
|
||||
items = buildSubtitleStreamInfo(context, stream, showFilePath),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
|
@ -185,6 +202,7 @@ private fun MediaInfoSection(
|
|||
title: String,
|
||||
items: List<Pair<String, String>>,
|
||||
modifier: Modifier = Modifier,
|
||||
additional: List<Pair<String, String>> = listOf(),
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
|
|
@ -192,66 +210,86 @@ private fun MediaInfoSection(
|
|||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
items.forEach { (label, value) ->
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "$label: ",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f, fill = false)) {
|
||||
items.forEach { (label, value) ->
|
||||
Row(
|
||||
modifier = Modifier,
|
||||
) {
|
||||
Text(
|
||||
text = "$label: ",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (additional.isNotEmpty()) {
|
||||
Column(modifier = Modifier.weight(1f, fill = false)) {
|
||||
additional.forEach { (label, value) ->
|
||||
Row(
|
||||
modifier = Modifier,
|
||||
) {
|
||||
Text(
|
||||
text = "$label: ",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun titleIndex(
|
||||
title: String,
|
||||
index: Int,
|
||||
total: Int,
|
||||
) = if (total > 1) {
|
||||
"$title (${index + 1})"
|
||||
} else {
|
||||
title
|
||||
}
|
||||
|
||||
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 titleLabel = context.getString(R.string.title)
|
||||
val codecLabel = context.getString(R.string.codec)
|
||||
val resolutionLabel = context.getString(R.string.resolution)
|
||||
val aspectRatioLabel = context.getString(R.string.aspect_ratio)
|
||||
val framerateLabel = context.getString(R.string.framerate)
|
||||
val bitrateLabel = context.getString(R.string.bitrate)
|
||||
val profileLabel = context.getString(R.string.profile)
|
||||
val levelLabel = context.getString(R.string.level)
|
||||
val interlacedLabel = context.getString(R.string.interlaced)
|
||||
val videoRangeLabel = context.getString(R.string.video_range)
|
||||
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}")
|
||||
}
|
||||
|
|
@ -259,23 +297,55 @@ private fun buildVideoStreamInfo(
|
|||
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.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) }
|
||||
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 {
|
||||
|
||||
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.profile?.let { add(profileLabel to it) }
|
||||
stream.level?.let { add(levelLabel to it.toString()) }
|
||||
stream.isInterlaced.let { add(interlacedLabel to if (it) yesStr else noStr) }
|
||||
}
|
||||
|
||||
private fun buildVideoStreamInfoAdditional(
|
||||
context: Context,
|
||||
stream: MediaStream,
|
||||
): List<Pair<String, String>> =
|
||||
buildList {
|
||||
val yesStr = context.getString(R.string.yes)
|
||||
val noStr = context.getString(R.string.no)
|
||||
|
||||
val avcLabel = context.getString(R.string.avc)
|
||||
val anamorphicLabel = context.getString(R.string.anamorphic)
|
||||
val bitDepthLabel = context.getString(R.string.bit_depth)
|
||||
|
||||
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 dolbyVisionLabel = context.getString(R.string.dolby_vision)
|
||||
|
||||
val sdrStr = context.getString(R.string.sdr)
|
||||
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.isAvc?.let { add(avcLabel to if (it) yesStr else noStr) }
|
||||
stream.isAnamorphic?.let { add(anamorphicLabel to if (it) yesStr else noStr) }
|
||||
stream.bitDepth?.let { add(bitDepthLabel to "$it $bitUnit") }
|
||||
stream.videoRangeType?.let {
|
||||
val rangeTypeStr =
|
||||
when (it) {
|
||||
|
|
@ -304,7 +374,8 @@ private fun buildVideoStreamInfo(
|
|||
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()) }
|
||||
stream.nalLengthSize?.let { add(nalLabel to it) }
|
||||
stream.videoDoViTitle?.let { add(dolbyVisionLabel to it) }
|
||||
}
|
||||
|
||||
private fun buildAudioStreamInfo(
|
||||
|
|
@ -320,7 +391,7 @@ private fun buildAudioStreamInfo(
|
|||
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 profileLabel = context.getString(R.string.profile)
|
||||
val yesStr = context.getString(R.string.yes)
|
||||
val noStr = context.getString(R.string.no)
|
||||
val sampleRateUnit = context.getString(R.string.sample_rate_unit)
|
||||
|
|
@ -328,11 +399,12 @@ private fun buildAudioStreamInfo(
|
|||
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()
|
||||
val formattedCodec = formatAudioCodec(context, it, stream.profile) + " ($it)"
|
||||
add(codecLabel to formattedCodec)
|
||||
}
|
||||
stream.channelLayout?.let { add(layoutLabel to it) }
|
||||
stream.channels?.let { add(channelsLabel to it.toString()) }
|
||||
stream.profile?.let { add(profileLabel to it) }
|
||||
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) }
|
||||
|
|
@ -341,28 +413,34 @@ private fun buildAudioStreamInfo(
|
|||
private fun buildSubtitleStreamInfo(
|
||||
context: Context,
|
||||
stream: MediaStream,
|
||||
showPath: Boolean,
|
||||
): 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)
|
||||
val pathLabel = context.getString(R.string.path)
|
||||
|
||||
stream.title?.let { add(titleLabel to it) }
|
||||
stream.language?.let { add(languageLabel to languageName(it)) }
|
||||
stream.codec?.let {
|
||||
val formattedCodec = formatSubtitleCodec(it) ?: it.uppercase()
|
||||
val formattedCodec = formatSubtitleCodec(it) + " ($it)"
|
||||
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) }
|
||||
stream.isHearingImpaired?.let {
|
||||
add((stream.localizedHearingImpaired ?: "SDH") to if (it) yesStr else noStr)
|
||||
}
|
||||
if (showPath) {
|
||||
stream.path?.let { add(pathLabel to it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateAspectRatio(
|
||||
|
|
|
|||
|
|
@ -73,12 +73,11 @@ 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 gridId: String
|
||||
val playable: Boolean
|
||||
val sortName: String
|
||||
}
|
||||
|
|
@ -117,12 +116,16 @@ fun <T : CardGridItem> CardGrid(
|
|||
val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0))
|
||||
|
||||
val fractionCacheWindow = LazyLayoutCacheWindow(aheadFraction = 1f, behindFraction = 0.5f)
|
||||
val gridState = rememberLazyGridState(cacheWindow = fractionCacheWindow)
|
||||
var focusedIndex by rememberSaveable { mutableIntStateOf(initialPosition) }
|
||||
val gridState =
|
||||
rememberLazyGridState(
|
||||
cacheWindow = fractionCacheWindow,
|
||||
initialFirstVisibleItemIndex = focusedIndex,
|
||||
)
|
||||
val scope = rememberCoroutineScope()
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
val zeroFocus = remember { FocusRequester() }
|
||||
var previouslyFocusedIndex by rememberSaveable { mutableIntStateOf(0) }
|
||||
var focusedIndex by rememberSaveable { mutableIntStateOf(initialPosition) }
|
||||
|
||||
var alphabetFocus by remember { mutableStateOf(false) }
|
||||
val focusOn = { index: Int ->
|
||||
|
|
@ -192,210 +195,224 @@ fun <T : CardGridItem> CardGrid(
|
|||
}
|
||||
}
|
||||
|
||||
var longPressing by remember { mutableStateOf(false) }
|
||||
Row(
|
||||
// horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.onKeyEvent {
|
||||
if (DEBUG) Timber.d("onKeyEvent: ${it.nativeKeyEvent}")
|
||||
if (useBackToJump && it.key == Key.Back && it.nativeKeyEvent.isLongPress) {
|
||||
longPressing = true
|
||||
val newPosition = previouslyFocusedIndex
|
||||
if (DEBUG) Timber.d("Back long pressed: newPosition=$newPosition")
|
||||
if (newPosition > 0) {
|
||||
focusOn(newPosition)
|
||||
scope.launch(ExceptionHandler()) {
|
||||
gridState.scrollToItem(newPosition, -columns)
|
||||
firstFocus.tryRequestFocus()
|
||||
}
|
||||
}
|
||||
return@onKeyEvent true
|
||||
} else if (it.type == KeyEventType.KeyUp) {
|
||||
if (longPressing && it.key == Key.Back) {
|
||||
longPressing = false
|
||||
return@onKeyEvent true
|
||||
}
|
||||
longPressing = false
|
||||
}
|
||||
if (it.type != KeyEventType.KeyUp) {
|
||||
return@onKeyEvent false
|
||||
} else if (useBackToJump && it.key == Key.Back && focusedIndex > 0) {
|
||||
jumpToTop()
|
||||
return@onKeyEvent true
|
||||
} else if (isPlayKeyUp(it)) {
|
||||
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)
|
||||
return@onKeyEvent true
|
||||
} else if (useJumpRemoteButtons && isBackwardButton(it)) {
|
||||
jump(-jump1)
|
||||
return@onKeyEvent true
|
||||
} else {
|
||||
return@onKeyEvent false
|
||||
}
|
||||
},
|
||||
) {
|
||||
if (showJumpButtons && pager.isNotEmpty()) {
|
||||
JumpButtons(
|
||||
jump1 = jump1,
|
||||
jump2 = jump2,
|
||||
jumpClick = { jump(it) },
|
||||
modifier = Modifier.align(Alignment.CenterVertically),
|
||||
if (pager.isEmpty()) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier.fillMaxSize(),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.no_results),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(columns),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing),
|
||||
state = gridState,
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.focusGroup()
|
||||
.focusRestorer(firstFocus)
|
||||
.focusProperties {
|
||||
onExit = {
|
||||
// Leaving the grid, so "forget" the position
|
||||
// focusedIndex = -1
|
||||
}
|
||||
onEnter = {
|
||||
if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) {
|
||||
focusedIndex = startPosition
|
||||
} else {
|
||||
var longPressing by remember { mutableStateOf(false) }
|
||||
Row(
|
||||
// horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.onKeyEvent {
|
||||
if (DEBUG) Timber.d("onKeyEvent: ${it.nativeKeyEvent}")
|
||||
if (useBackToJump && it.key == Key.Back && it.nativeKeyEvent.isLongPress) {
|
||||
longPressing = true
|
||||
val newPosition = previouslyFocusedIndex
|
||||
if (DEBUG) Timber.d("Back long pressed: newPosition=$newPosition")
|
||||
if (newPosition > 0) {
|
||||
focusOn(newPosition)
|
||||
scope.launch(ExceptionHandler()) {
|
||||
gridState.scrollToItem(newPosition, -columns)
|
||||
firstFocus.tryRequestFocus()
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
items(pager.size) { index ->
|
||||
val mod =
|
||||
if ((index == focusedIndex) or (focusedIndex < 0 && index == 0)) {
|
||||
if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index")
|
||||
Modifier
|
||||
.focusRequester(firstFocus)
|
||||
.focusRequester(gridFocusRequester)
|
||||
.focusRequester(alphabetFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
return@onKeyEvent true
|
||||
} else if (it.type == KeyEventType.KeyUp) {
|
||||
if (longPressing && it.key == Key.Back) {
|
||||
longPressing = false
|
||||
return@onKeyEvent true
|
||||
}
|
||||
longPressing = false
|
||||
}
|
||||
val item = pager[index]
|
||||
cardContent(
|
||||
item,
|
||||
{
|
||||
if (item != null) {
|
||||
focusedIndex = index
|
||||
onClickItem.invoke(index, item)
|
||||
if (it.type != KeyEventType.KeyUp) {
|
||||
return@onKeyEvent false
|
||||
} else if (useBackToJump && it.key == Key.Back && focusedIndex > 0) {
|
||||
jumpToTop()
|
||||
return@onKeyEvent true
|
||||
} else if (isPlayKeyUp(it)) {
|
||||
val item = pager.getOrNull(focusedIndex)
|
||||
if (item?.playable == true) {
|
||||
Timber.v("Clicked play on ${item.gridId}")
|
||||
onClickPlay.invoke(focusedIndex, item)
|
||||
}
|
||||
},
|
||||
{ if (item != null) onLongClickItem.invoke(index, item) },
|
||||
mod
|
||||
.ifElse(index == 0, Modifier.focusRequester(zeroFocus))
|
||||
.onFocusChanged { focusState ->
|
||||
if (DEBUG) {
|
||||
Timber.v(
|
||||
"$index isFocused=${focusState.isFocused}",
|
||||
)
|
||||
return@onKeyEvent true
|
||||
} else if (useJumpRemoteButtons && isForwardButton(it)) {
|
||||
jump(jump1)
|
||||
return@onKeyEvent true
|
||||
} else if (useJumpRemoteButtons && isBackwardButton(it)) {
|
||||
jump(-jump1)
|
||||
return@onKeyEvent true
|
||||
} else {
|
||||
return@onKeyEvent false
|
||||
}
|
||||
},
|
||||
) {
|
||||
if (showJumpButtons && pager.isNotEmpty()) {
|
||||
JumpButtons(
|
||||
jump1 = jump1,
|
||||
jump2 = jump2,
|
||||
jumpClick = { jump(it) },
|
||||
modifier = Modifier.align(Alignment.CenterVertically),
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(columns),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing),
|
||||
state = gridState,
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.focusGroup()
|
||||
.focusRestorer(firstFocus)
|
||||
.focusProperties {
|
||||
onExit = {
|
||||
// Leaving the grid, so "forget" the position
|
||||
// focusedIndex = -1
|
||||
}
|
||||
if (focusState.isFocused) {
|
||||
// Focused, so set that up
|
||||
focusOn(index)
|
||||
positionCallback?.invoke(columns, index)
|
||||
} else if (focusedIndex == index) {
|
||||
onEnter = {
|
||||
if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) {
|
||||
focusedIndex = startPosition
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
items(pager.size) { index ->
|
||||
val mod =
|
||||
if ((index == focusedIndex) or (focusedIndex < 0 && index == 0)) {
|
||||
if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index")
|
||||
Modifier
|
||||
.focusRequester(firstFocus)
|
||||
.focusRequester(gridFocusRequester)
|
||||
.focusRequester(alphabetFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
val item = pager[index]
|
||||
cardContent(
|
||||
item,
|
||||
{
|
||||
if (item != null) {
|
||||
focusedIndex = index
|
||||
onClickItem.invoke(index, item)
|
||||
}
|
||||
},
|
||||
{ if (item != null) onLongClickItem.invoke(index, item) },
|
||||
mod
|
||||
.ifElse(index == 0, Modifier.focusRequester(zeroFocus))
|
||||
.onFocusChanged { focusState ->
|
||||
if (DEBUG) {
|
||||
Timber.v(
|
||||
"$index isFocused=${focusState.isFocused}",
|
||||
)
|
||||
}
|
||||
if (focusState.isFocused) {
|
||||
// Focused, so set that up
|
||||
focusOn(index)
|
||||
positionCallback?.invoke(columns, index)
|
||||
} else if (focusedIndex == index) {
|
||||
// savedFocusedIndex = index
|
||||
// // Was focused on this, so mark unfocused
|
||||
// focusedIndex = -1
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pager.isEmpty()) {
|
||||
if (pager.isEmpty()) {
|
||||
// focusedIndex = -1
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Text(
|
||||
text = stringResource(R.string.no_results),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Text(
|
||||
text = stringResource(R.string.no_results),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showFooter) {
|
||||
// Footer
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(AppColors.TransparentBlack50),
|
||||
) {
|
||||
val index = (focusedIndex + 1).takeIf { it > 0 } ?: "?"
|
||||
if (showFooter) {
|
||||
// Footer
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(AppColors.TransparentBlack50),
|
||||
) {
|
||||
val index = (focusedIndex + 1).takeIf { it > 0 } ?: "?"
|
||||
// if (focusedIndex >= 0) {
|
||||
// focusedIndex + 1
|
||||
// } else {
|
||||
// max(savedFocusedIndex, focusedIndexOnExit) + 1
|
||||
// }
|
||||
Text(
|
||||
modifier = Modifier.padding(4.dp),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
text = "$index / ${pager.size}",
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.padding(4.dp),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
text = "$index / ${pager.size}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val context = LocalContext.current
|
||||
val letters = context.getString(R.string.jump_letters)
|
||||
// Letters
|
||||
val currentLetter =
|
||||
remember(focusedIndex) {
|
||||
pager
|
||||
.getOrNull(focusedIndex)
|
||||
?.sortName
|
||||
?.first()
|
||||
?.uppercaseChar()
|
||||
?.let {
|
||||
if (it >= '0' && it <= '9') {
|
||||
'#'
|
||||
} else if (it >= 'A' && it <= 'Z') {
|
||||
it
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
?: letters[0]
|
||||
}
|
||||
if (showLetterButtons && pager.isNotEmpty()) {
|
||||
AlphabetButtons(
|
||||
letters = letters,
|
||||
currentLetter = currentLetter,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.CenterVertically)
|
||||
.padding(end = 16.dp),
|
||||
// Add end padding to push away from edge
|
||||
letterClicked = { letter ->
|
||||
scope.launch(ExceptionHandler()) {
|
||||
val jumpPosition =
|
||||
withContext(Dispatchers.IO) {
|
||||
letterPosition.invoke(letter)
|
||||
val context = LocalContext.current
|
||||
val letters = context.getString(R.string.jump_letters)
|
||||
// Letters
|
||||
val currentLetter =
|
||||
remember(focusedIndex) {
|
||||
pager
|
||||
.getOrNull(focusedIndex)
|
||||
?.sortName
|
||||
?.firstOrNull()
|
||||
?.uppercaseChar()
|
||||
?.let {
|
||||
if (it >= '0' && it <= '9') {
|
||||
'#'
|
||||
} else if (it >= 'A' && it <= 'Z') {
|
||||
it
|
||||
} else {
|
||||
null
|
||||
}
|
||||
Timber.d("Alphabet jump to $jumpPosition")
|
||||
if (jumpPosition >= 0) {
|
||||
pager.getOrNull(jumpPosition)
|
||||
gridState.scrollToItem(jumpPosition)
|
||||
focusOn(jumpPosition)
|
||||
alphabetFocus = true
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
?: letters[0]
|
||||
}
|
||||
if (showLetterButtons && pager.isNotEmpty()) {
|
||||
AlphabetButtons(
|
||||
letters = letters,
|
||||
currentLetter = currentLetter,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.CenterVertically)
|
||||
.padding(end = 16.dp),
|
||||
// Add end padding to push away from edge
|
||||
letterClicked = { letter ->
|
||||
scope.launch(ExceptionHandler()) {
|
||||
val jumpPosition =
|
||||
withContext(Dispatchers.IO) {
|
||||
letterPosition.invoke(letter)
|
||||
}
|
||||
Timber.d("Alphabet jump to $jumpPosition")
|
||||
if (jumpPosition >= 0) {
|
||||
pager.getOrNull(jumpPosition)
|
||||
gridState.scrollToItem(jumpPosition)
|
||||
focusOn(jumpPosition)
|
||||
alphabetFocus = true
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import androidx.compose.runtime.setValue
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
|
||||
|
|
@ -25,7 +24,6 @@ import java.util.UUID
|
|||
fun CollectionFolderBoxSet(
|
||||
preferences: UserPreferences,
|
||||
itemId: UUID,
|
||||
item: BaseItem?,
|
||||
recursive: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
filter: CollectionFolderFilter = CollectionFolderFilter(),
|
||||
|
|
|
|||
|
|
@ -93,14 +93,19 @@ fun CollectionFolderLiveTv(
|
|||
remember { viewModel.getRememberedTab(preferences, destination.itemId, 0) }
|
||||
val folders by viewModel.recordingFolders.observeAsState(listOf())
|
||||
|
||||
val tvGuideStr = stringResource(R.string.tv_guide)
|
||||
val tvDvrStr = stringResource(R.string.tv_dvr_schedule)
|
||||
val tabs =
|
||||
listOf(
|
||||
TabId(stringResource(R.string.tv_guide), UUID.randomUUID()),
|
||||
TabId(stringResource(R.string.tv_dvr_schedule), UUID.randomUUID()),
|
||||
) + folders
|
||||
remember(folders) {
|
||||
listOf(
|
||||
TabId(tvGuideStr, UUID.randomUUID()),
|
||||
TabId(tvDvrStr, UUID.randomUUID()),
|
||||
) + folders
|
||||
}
|
||||
|
||||
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val tabFocusRequesters = remember(tabs.size) { List(tabs.size) { FocusRequester() } }
|
||||
|
||||
val firstTabFocusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
|
||||
|
|
@ -133,6 +138,7 @@ fun CollectionFolderLiveTv(
|
|||
.focusRequester(firstTabFocusRequester),
|
||||
tabs = tabs.map { it.title },
|
||||
onClick = { selectedTabIndex = it },
|
||||
focusRequesters = tabFocusRequesters,
|
||||
)
|
||||
}
|
||||
when (selectedTabIndex) {
|
||||
|
|
@ -150,10 +156,12 @@ fun CollectionFolderLiveTv(
|
|||
|
||||
1 -> {
|
||||
DvrSchedule(
|
||||
true,
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.focusRequester(focusRequester),
|
||||
requestFocusAfterLoading = true,
|
||||
focusRequesterOnEmpty = tabFocusRequesters[1],
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.focusRequester(focusRequester),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -176,6 +184,7 @@ fun CollectionFolderLiveTv(
|
|||
},
|
||||
playEnabled = false,
|
||||
defaultViewOptions = ViewOptions(),
|
||||
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
|
||||
)
|
||||
} else {
|
||||
ErrorMessage("Invalid tab index $selectedTabIndex", null)
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ import com.github.damontecres.wholphin.ui.data.VideoSortOptions
|
|||
import com.github.damontecres.wholphin.ui.logTab
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
|
||||
@Composable
|
||||
|
|
@ -59,9 +58,10 @@ fun CollectionFolderMovie(
|
|||
)
|
||||
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } }
|
||||
|
||||
val firstTabFocusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
|
||||
// LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
|
||||
|
||||
LaunchedEffect(selectedTabIndex) {
|
||||
logTab("movie", selectedTabIndex)
|
||||
|
|
@ -71,7 +71,6 @@ fun CollectionFolderMovie(
|
|||
|
||||
var showHeader by rememberSaveable { mutableStateOf(true) }
|
||||
|
||||
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||
Column(
|
||||
modifier = modifier,
|
||||
) {
|
||||
|
|
@ -88,6 +87,7 @@ fun CollectionFolderMovie(
|
|||
.focusRequester(firstTabFocusRequester),
|
||||
tabs = tabs,
|
||||
onClick = { selectedTabIndex = it },
|
||||
focusRequesters = tabFocusRequesters,
|
||||
)
|
||||
}
|
||||
when (selectedTabIndex) {
|
||||
|
|
@ -115,6 +115,7 @@ fun CollectionFolderMovie(
|
|||
preferencesViewModel.navigationManager.navigateTo(item.destination())
|
||||
},
|
||||
itemId = destination.itemId,
|
||||
viewModelKey = "${destination.itemId}_library",
|
||||
initialFilter =
|
||||
CollectionFolderFilter(
|
||||
filter =
|
||||
|
|
@ -135,6 +136,7 @@ fun CollectionFolderMovie(
|
|||
showHeader = position < columns
|
||||
},
|
||||
playEnabled = true,
|
||||
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -146,6 +148,7 @@ fun CollectionFolderMovie(
|
|||
preferencesViewModel.navigationManager.navigateTo(item.destination())
|
||||
},
|
||||
itemId = destination.itemId,
|
||||
viewModelKey = "${destination.itemId}_collection",
|
||||
initialFilter =
|
||||
CollectionFolderFilter(
|
||||
filter =
|
||||
|
|
@ -166,6 +169,7 @@ fun CollectionFolderMovie(
|
|||
showHeader = position < columns
|
||||
},
|
||||
playEnabled = false,
|
||||
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -173,6 +177,7 @@ fun CollectionFolderMovie(
|
|||
3 -> {
|
||||
GenreCardGrid(
|
||||
itemId = destination.itemId,
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 16.dp)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import androidx.compose.runtime.setValue
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
|
||||
|
|
@ -22,7 +21,6 @@ import java.util.UUID
|
|||
fun CollectionFolderPlaylist(
|
||||
preferences: UserPreferences,
|
||||
itemId: UUID,
|
||||
item: BaseItem?,
|
||||
recursive: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
filter: CollectionFolderFilter = CollectionFolderFilter(),
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ fun CollectionFolderTv(
|
|||
)
|
||||
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } }
|
||||
|
||||
val firstTabFocusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
|
||||
|
|
@ -74,7 +75,6 @@ fun CollectionFolderTv(
|
|||
|
||||
var showHeader by rememberSaveable { mutableStateOf(true) }
|
||||
|
||||
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||
Column(
|
||||
modifier = modifier,
|
||||
) {
|
||||
|
|
@ -91,6 +91,7 @@ fun CollectionFolderTv(
|
|||
.focusRequester(firstTabFocusRequester),
|
||||
tabs = tabs,
|
||||
onClick = { selectedTabIndex = it },
|
||||
focusRequesters = tabFocusRequesters,
|
||||
)
|
||||
}
|
||||
when (selectedTabIndex) {
|
||||
|
|
@ -138,6 +139,7 @@ fun CollectionFolderTv(
|
|||
preferencesViewModel.navigationManager.navigateTo(item.destination())
|
||||
},
|
||||
playEnabled = false,
|
||||
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -145,6 +147,7 @@ fun CollectionFolderTv(
|
|||
2 -> {
|
||||
GenreCardGrid(
|
||||
itemId = destination.itemId,
|
||||
includeItemTypes = listOf(BaseItemKind.SERIES),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 16.dp)
|
||||
|
|
|
|||
|
|
@ -254,7 +254,7 @@ fun DebugPage(
|
|||
"Manufacturer: ${Build.MANUFACTURER}",
|
||||
"Model: ${Build.MODEL}",
|
||||
"Display Modes:",
|
||||
*viewModel.refreshRateService.displayModes,
|
||||
*viewModel.refreshRateService.supportedDisplayModes,
|
||||
).forEach {
|
||||
Text(
|
||||
text = it.toString(),
|
||||
|
|
|
|||
|
|
@ -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.Delete
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
|
|
@ -29,6 +30,12 @@ data class MoreDialogActions(
|
|||
var onClickAddPlaylist: (UUID) -> Unit,
|
||||
)
|
||||
|
||||
enum class ClearChosenStreams {
|
||||
NONE,
|
||||
ITEM_AND_SERIES,
|
||||
SERIES,
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the [DialogItem]s when clicking "More"
|
||||
*
|
||||
|
|
@ -53,10 +60,12 @@ fun buildMoreDialogItems(
|
|||
sourceId: UUID?,
|
||||
watched: Boolean,
|
||||
favorite: Boolean,
|
||||
canClearChosenStreams: Boolean,
|
||||
actions: MoreDialogActions,
|
||||
onChooseVersion: () -> Unit,
|
||||
onChooseTracks: (MediaStreamType) -> Unit,
|
||||
onShowOverview: () -> Unit,
|
||||
onClearChosenStreams: () -> Unit,
|
||||
): List<DialogItem> =
|
||||
buildList {
|
||||
add(
|
||||
|
|
@ -69,7 +78,6 @@ fun buildMoreDialogItems(
|
|||
Destination.Playback(
|
||||
item.id,
|
||||
item.resumeMs ?: 0L,
|
||||
item,
|
||||
),
|
||||
)
|
||||
},
|
||||
|
|
@ -157,6 +165,7 @@ fun buildMoreDialogItems(
|
|||
Destination.MediaItem(
|
||||
seriesId,
|
||||
BaseItemKind.SERIES,
|
||||
null,
|
||||
),
|
||||
)
|
||||
},
|
||||
|
|
@ -172,6 +181,16 @@ fun buildMoreDialogItems(
|
|||
},
|
||||
)
|
||||
}
|
||||
if (canClearChosenStreams) {
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.clear_track_choices),
|
||||
Icons.Default.Delete,
|
||||
) {
|
||||
onClearChosenStreams()
|
||||
},
|
||||
)
|
||||
}
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.play_with_transcoding),
|
||||
|
|
@ -181,7 +200,6 @@ fun buildMoreDialogItems(
|
|||
Destination.Playback(
|
||||
item.id,
|
||||
item.resumeMs ?: 0L,
|
||||
item,
|
||||
forceTranscoding = true,
|
||||
),
|
||||
)
|
||||
|
|
@ -290,6 +308,7 @@ fun buildMoreDialogItemsForHome(
|
|||
Destination.MediaItem(
|
||||
it,
|
||||
BaseItemKind.SERIES,
|
||||
null,
|
||||
),
|
||||
)
|
||||
},
|
||||
|
|
@ -309,7 +328,7 @@ fun buildMoreDialogItemsForPerson(
|
|||
context.getString(R.string.go_to),
|
||||
Icons.Default.ArrowForward,
|
||||
) {
|
||||
actions.navigateTo(Destination.MediaItem(itemId, BaseItemKind.PERSON))
|
||||
actions.navigateTo(Destination.MediaItem(itemId, BaseItemKind.PERSON, null))
|
||||
},
|
||||
)
|
||||
add(
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ fun FavoritesPage(
|
|||
stringResource(R.string.playlists),
|
||||
stringResource(R.string.people),
|
||||
)
|
||||
val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } }
|
||||
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
|
|
@ -107,6 +108,7 @@ fun FavoritesPage(
|
|||
.padding(start = 32.dp, top = 16.dp, bottom = 16.dp),
|
||||
tabs = tabs,
|
||||
onClick = { selectedTabIndex = it },
|
||||
focusRequesters = tabFocusRequesters,
|
||||
)
|
||||
}
|
||||
// TODO playEnabled = true for movies & episodes
|
||||
|
|
@ -139,6 +141,7 @@ fun FavoritesPage(
|
|||
},
|
||||
playEnabled = false,
|
||||
filterOptions = DefaultForFavoritesFilterOptions,
|
||||
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -170,6 +173,7 @@ fun FavoritesPage(
|
|||
},
|
||||
playEnabled = false,
|
||||
filterOptions = DefaultForFavoritesFilterOptions,
|
||||
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -202,6 +206,7 @@ fun FavoritesPage(
|
|||
},
|
||||
playEnabled = false,
|
||||
filterOptions = DefaultForFavoritesFilterOptions,
|
||||
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -233,6 +238,7 @@ fun FavoritesPage(
|
|||
},
|
||||
playEnabled = false,
|
||||
filterOptions = DefaultForFavoritesFilterOptions,
|
||||
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -264,6 +270,7 @@ fun FavoritesPage(
|
|||
},
|
||||
playEnabled = false,
|
||||
filterOptions = DefaultForFavoritesFilterOptions,
|
||||
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -300,6 +307,7 @@ fun FavoritesPage(
|
|||
},
|
||||
playEnabled = false,
|
||||
filterOptions = listOf(),
|
||||
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ abstract class ItemViewModel(
|
|||
) : ViewModel() {
|
||||
val item = MutableLiveData<BaseItem?>(null)
|
||||
lateinit var itemId: String
|
||||
lateinit var itemUuid: UUID
|
||||
var itemUuid: UUID? = null
|
||||
|
||||
suspend fun fetchItem(itemId: UUID): BaseItem =
|
||||
withContext(Dispatchers.IO) {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester
|
|||
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
|
||||
|
|
@ -40,13 +41,17 @@ import androidx.tv.material3.surfaceColorAtElevation
|
|||
import coil3.compose.AsyncImage
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.SeerrService
|
||||
import com.github.damontecres.wholphin.ui.Cards
|
||||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
import com.github.damontecres.wholphin.ui.cards.SeasonCard
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
|
|
@ -54,7 +59,11 @@ import com.github.damontecres.wholphin.ui.components.LoadingRow
|
|||
import com.github.damontecres.wholphin.ui.components.OverviewText
|
||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
|
||||
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
|
||||
import com.github.damontecres.wholphin.ui.formatDate
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
|
|
@ -63,11 +72,14 @@ import com.github.damontecres.wholphin.ui.setValueOnMain
|
|||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import com.github.damontecres.wholphin.util.RowLoadingState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
|
|
@ -86,10 +98,12 @@ class PersonViewModel
|
|||
api: ApiClient,
|
||||
val navigationManager: NavigationManager,
|
||||
private val favoriteWatchManager: FavoriteWatchManager,
|
||||
private val seerrService: SeerrService,
|
||||
) : LoadingItemViewModel(api) {
|
||||
val movies = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
|
||||
val series = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
|
||||
val episodes = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
|
||||
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
|
||||
|
||||
fun init(itemId: UUID) {
|
||||
viewModelScope.launchIO(
|
||||
|
|
@ -115,6 +129,10 @@ class PersonViewModel
|
|||
} else {
|
||||
episodes.setValueOnMain(RowLoadingState.Success(listOf()))
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
val results = seerrService.similar(person).orEmpty()
|
||||
discovered.update { results }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -155,8 +173,10 @@ class PersonViewModel
|
|||
|
||||
fun setFavorite(favorite: Boolean) {
|
||||
viewModelScope.launchIO {
|
||||
favoriteWatchManager.setFavorite(itemUuid, favorite)
|
||||
fetchAndSetItem(itemUuid)
|
||||
itemUuid?.let {
|
||||
favoriteWatchManager.setFavorite(it, favorite)
|
||||
fetchAndSetItem(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -175,6 +195,7 @@ fun PersonPage(
|
|||
val movies by viewModel.movies.observeAsState(RowLoadingState.Pending)
|
||||
val series by viewModel.series.observeAsState(RowLoadingState.Pending)
|
||||
val episodes by viewModel.episodes.observeAsState(RowLoadingState.Pending)
|
||||
val discovered by viewModel.discovered.collectAsState()
|
||||
|
||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||
when (val state = loading) {
|
||||
|
|
@ -213,6 +234,10 @@ fun PersonPage(
|
|||
favoriteOnClick = {
|
||||
viewModel.setFavorite(!person.favorite)
|
||||
},
|
||||
discovered = discovered,
|
||||
onClickDiscover = { index, item ->
|
||||
viewModel.navigationManager.navigateTo(item.destination)
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
AnimatedVisibility(showOverviewDialog) {
|
||||
|
|
@ -237,6 +262,7 @@ private const val HEADER_ROW = 0
|
|||
private const val MOVIE_ROW = 1
|
||||
private const val SERIES_ROW = 2
|
||||
private const val EPISODE_ROW = 3
|
||||
private const val DISCOVER_ROW = EPISODE_ROW + 1
|
||||
|
||||
@Composable
|
||||
fun PersonPageContent(
|
||||
|
|
@ -251,9 +277,11 @@ fun PersonPageContent(
|
|||
movies: RowLoadingState,
|
||||
series: RowLoadingState,
|
||||
episodes: RowLoadingState,
|
||||
discovered: List<DiscoverItem>,
|
||||
onClickItem: (Int, BaseItem) -> Unit,
|
||||
overviewOnClick: () -> Unit,
|
||||
favoriteOnClick: () -> Unit,
|
||||
onClickDiscover: (Int, DiscoverItem) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
|
@ -332,10 +360,45 @@ fun PersonPageContent(
|
|||
onClickItem = onClickItem,
|
||||
onClickPosition = { position = it },
|
||||
showIfEmpty = false,
|
||||
horizontalPadding = 32.dp,
|
||||
horizontalPadding = 24.dp,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
cardContent = { index, item, mod, onClick, onLongClick ->
|
||||
SeasonCard(
|
||||
item = item,
|
||||
onClick = {
|
||||
position = RowColumn(EPISODE_ROW, index)
|
||||
onClick.invoke()
|
||||
},
|
||||
onLongClick = onLongClick,
|
||||
imageHeight = Cards.heightEpisode,
|
||||
modifier =
|
||||
mod
|
||||
.ifElse(
|
||||
position.row == EPISODE_ROW && position.column == index,
|
||||
Modifier.focusRequester(focusRequester),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (discovered.isNotEmpty()) {
|
||||
item {
|
||||
DiscoverRow(
|
||||
row =
|
||||
DiscoverRowData(
|
||||
stringResource(R.string.discover),
|
||||
DataLoadingState.Success(discovered),
|
||||
),
|
||||
onClickItem = { index: Int, item: DiscoverItem ->
|
||||
position = RowColumn(DISCOVER_ROW, index)
|
||||
onClickDiscover.invoke(index, item)
|
||||
},
|
||||
onLongClickItem = { _, _ -> },
|
||||
onCardFocus = {},
|
||||
focusRequester = focusRequester,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -410,7 +410,7 @@ fun PlaylistItem(
|
|||
},
|
||||
trailingContent = {
|
||||
item?.data?.runTimeTicks?.ticks?.roundMinutes?.let { duration ->
|
||||
val now = LocalClock.current.now
|
||||
val now by LocalClock.current.now
|
||||
val endTimeStr =
|
||||
remember(item, now) {
|
||||
val endTime = now.toLocalTime().plusSeconds(duration.inWholeSeconds)
|
||||
|
|
@ -444,6 +444,7 @@ fun PlaylistItem(
|
|||
watched = item?.data?.userData?.played ?: false,
|
||||
unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1,
|
||||
watchedPercent = 0.0,
|
||||
numberOfVersions = item?.data?.mediaSourceCount ?: 0,
|
||||
modifier = Modifier.width(160.dp),
|
||||
useFallbackText = false,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,471 @@
|
|||
package com.github.damontecres.wholphin.ui.detail.discover
|
||||
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||
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
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverRating
|
||||
import com.github.damontecres.wholphin.data.model.LocalTrailer
|
||||
import com.github.damontecres.wholphin.data.model.RemoteTrailer
|
||||
import com.github.damontecres.wholphin.data.model.SeerrAvailability
|
||||
import com.github.damontecres.wholphin.data.model.SeerrPermission
|
||||
import com.github.damontecres.wholphin.data.model.Trailer
|
||||
import com.github.damontecres.wholphin.data.model.hasPermission
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.SeerrUserConfig
|
||||
import com.github.damontecres.wholphin.services.TrailerService
|
||||
import com.github.damontecres.wholphin.ui.Cards
|
||||
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
|
||||
import com.github.damontecres.wholphin.ui.cards.DiscoverPersonRow
|
||||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||
import com.github.damontecres.wholphin.ui.cards.SeasonCard
|
||||
import com.github.damontecres.wholphin.ui.components.DialogItem
|
||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.rememberInt
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
|
||||
@Composable
|
||||
fun DiscoverMovieDetails(
|
||||
preferences: UserPreferences,
|
||||
destination: Destination.DiscoveredItem,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: DiscoverMovieViewModel =
|
||||
hiltViewModel<DiscoverMovieViewModel, DiscoverMovieViewModel.Factory>(
|
||||
creationCallback = { it.create(destination.item) },
|
||||
),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
LifecycleResumeEffect(Unit) {
|
||||
viewModel.init()
|
||||
onPauseOrDispose { }
|
||||
}
|
||||
val item by viewModel.movie.observeAsState()
|
||||
val rating by viewModel.rating.observeAsState(null)
|
||||
val people by viewModel.people.observeAsState(listOf())
|
||||
val trailers by viewModel.trailers.observeAsState(listOf())
|
||||
val similar by viewModel.similar.observeAsState(listOf())
|
||||
val recommended by viewModel.similar.observeAsState(listOf())
|
||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||
val userConfig by viewModel.userConfig.collectAsState(null)
|
||||
val request4kEnabled by viewModel.request4kEnabled.collectAsState(false)
|
||||
val canCancel by viewModel.canCancelRequest.collectAsState()
|
||||
|
||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||
|
||||
val requestStr = stringResource(R.string.request)
|
||||
val request4kStr = stringResource(R.string.request_4k)
|
||||
|
||||
val moreActions =
|
||||
MoreDialogActions(
|
||||
navigateTo = viewModel::navigateTo,
|
||||
onClickWatch = { itemId, watched -> },
|
||||
onClickFavorite = { itemId, favorite -> },
|
||||
onClickAddPlaylist = { itemId -> },
|
||||
)
|
||||
|
||||
when (val state = loading) {
|
||||
is LoadingState.Error -> {
|
||||
ErrorMessage(state)
|
||||
}
|
||||
|
||||
LoadingState.Loading,
|
||||
LoadingState.Pending,
|
||||
-> {
|
||||
LoadingPage()
|
||||
}
|
||||
|
||||
LoadingState.Success -> {
|
||||
item?.let { movie ->
|
||||
DiscoverMovieDetailsContent(
|
||||
preferences = preferences,
|
||||
movie = movie,
|
||||
userConfig = userConfig,
|
||||
rating = rating,
|
||||
canCancel = canCancel,
|
||||
people = people,
|
||||
trailers = trailers,
|
||||
similar = similar,
|
||||
recommended = recommended,
|
||||
requestOnClick = {
|
||||
movie.id?.let { id ->
|
||||
if (request4kEnabled) {
|
||||
moreDialog =
|
||||
DialogParams(
|
||||
fromLongClick = false,
|
||||
title = movie.title + " (${movie.releaseDate ?: ""})",
|
||||
items =
|
||||
listOf(
|
||||
DialogItem(
|
||||
text = requestStr,
|
||||
onClick = { viewModel.request(id, false) },
|
||||
),
|
||||
DialogItem(
|
||||
text = request4kStr,
|
||||
onClick = { viewModel.request(id, true) },
|
||||
),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
viewModel.request(id, false)
|
||||
}
|
||||
}
|
||||
},
|
||||
cancelOnClick = {
|
||||
movie.id?.let { viewModel.cancelRequest(it) }
|
||||
},
|
||||
onClickItem = { index, item ->
|
||||
viewModel.navigateTo(Destination.DiscoveredItem(item))
|
||||
},
|
||||
onClickPerson = { item ->
|
||||
viewModel.navigateTo(Destination.DiscoveredItem(item))
|
||||
},
|
||||
overviewOnClick = {
|
||||
overviewDialog =
|
||||
ItemDetailsDialogInfo(
|
||||
title = movie.title ?: context.getString(R.string.unknown),
|
||||
overview = movie.overview,
|
||||
genres = movie.genres?.mapNotNull { it.name }.orEmpty(),
|
||||
files = listOf(),
|
||||
)
|
||||
},
|
||||
goToOnClick = {
|
||||
movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull()?.let {
|
||||
viewModel.navigateTo(
|
||||
Destination.MediaItem(
|
||||
itemId = it,
|
||||
type = BaseItemKind.MOVIE,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
moreOnClick = {
|
||||
moreDialog =
|
||||
DialogParams(
|
||||
fromLongClick = false,
|
||||
title = movie.title + " (${movie.releaseDate ?: ""})",
|
||||
items = listOf(),
|
||||
)
|
||||
},
|
||||
onLongClickPerson = { index, person -> },
|
||||
onLongClickSimilar = { index, similar ->
|
||||
},
|
||||
trailerOnClick = {
|
||||
TrailerService.onClick(context, it, viewModel::navigateTo)
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
overviewDialog?.let { info ->
|
||||
ItemDetailsDialog(
|
||||
info = info,
|
||||
showFilePath = false,
|
||||
onDismissRequest = { overviewDialog = null },
|
||||
)
|
||||
}
|
||||
moreDialog?.let { params ->
|
||||
DialogPopup(
|
||||
showDialog = true,
|
||||
title = params.title,
|
||||
dialogItems = params.items,
|
||||
onDismissRequest = { moreDialog = null },
|
||||
dismissOnClick = true,
|
||||
waitToLoad = params.fromLongClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val HEADER_ROW = 0
|
||||
private const val PEOPLE_ROW = HEADER_ROW + 1
|
||||
private const val CHAPTER_ROW = PEOPLE_ROW + 1
|
||||
private const val EXTRAS_ROW = CHAPTER_ROW + 1
|
||||
private const val SIMILAR_ROW = EXTRAS_ROW + 1
|
||||
private const val RECOMMENDED_ROW = SIMILAR_ROW + 1
|
||||
|
||||
@Composable
|
||||
fun DiscoverMovieDetailsContent(
|
||||
preferences: UserPreferences,
|
||||
userConfig: SeerrUserConfig?,
|
||||
movie: MovieDetails,
|
||||
rating: DiscoverRating?,
|
||||
canCancel: Boolean,
|
||||
people: List<DiscoverItem>,
|
||||
trailers: List<Trailer>,
|
||||
similar: List<DiscoverItem>,
|
||||
recommended: List<DiscoverItem>,
|
||||
requestOnClick: () -> Unit,
|
||||
cancelOnClick: () -> Unit,
|
||||
trailerOnClick: (Trailer) -> Unit,
|
||||
overviewOnClick: () -> Unit,
|
||||
goToOnClick: () -> Unit,
|
||||
moreOnClick: () -> Unit,
|
||||
onClickItem: (Int, DiscoverItem) -> Unit,
|
||||
onClickPerson: (DiscoverItem) -> Unit,
|
||||
onLongClickPerson: (Int, DiscoverItem) -> Unit,
|
||||
onLongClickSimilar: (Int, DiscoverItem) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var position by rememberInt(0)
|
||||
val focusRequesters = remember { List(RECOMMENDED_ROW + 1) { FocusRequester() } }
|
||||
|
||||
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequesters.getOrNull(position)?.tryRequestFocus()
|
||||
}
|
||||
Box(modifier = modifier) {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
item {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.bringIntoViewRequester(bringIntoViewRequester),
|
||||
) {
|
||||
DiscoverMovieDetailsHeader(
|
||||
preferences = preferences,
|
||||
movie = movie,
|
||||
rating = rating,
|
||||
bringIntoViewRequester = bringIntoViewRequester,
|
||||
overviewOnClick = overviewOnClick,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 32.dp, bottom = 16.dp),
|
||||
)
|
||||
ExpandableDiscoverButtons(
|
||||
availability =
|
||||
SeerrAvailability.from(movie.mediaInfo?.status)
|
||||
?: SeerrAvailability.UNKNOWN,
|
||||
requestOnClick = requestOnClick,
|
||||
cancelOnClick = cancelOnClick,
|
||||
moreOnClick = moreOnClick,
|
||||
goToOnClick = goToOnClick,
|
||||
buttonOnFocusChanged = {
|
||||
if (it.isFocused) {
|
||||
position = HEADER_ROW
|
||||
scope.launch(ExceptionHandler()) {
|
||||
bringIntoViewRequester.bringIntoView()
|
||||
}
|
||||
}
|
||||
},
|
||||
canRequest = userConfig.hasPermission(SeerrPermission.REQUEST),
|
||||
canCancel = canCancel,
|
||||
trailers = trailers,
|
||||
trailerOnClick = trailerOnClick,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp)
|
||||
.focusRequester(focusRequesters[HEADER_ROW]),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (people.isNotEmpty()) {
|
||||
item {
|
||||
DiscoverPersonRow(
|
||||
people = people,
|
||||
onClick = {
|
||||
position = PEOPLE_ROW
|
||||
onClickPerson.invoke(it)
|
||||
},
|
||||
onLongClick = { index, person ->
|
||||
position = PEOPLE_ROW
|
||||
onLongClickPerson.invoke(index, person)
|
||||
},
|
||||
modifier = Modifier.focusRequester(focusRequesters[PEOPLE_ROW]),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (similar.isNotEmpty()) {
|
||||
item {
|
||||
ItemRow(
|
||||
title = stringResource(R.string.more_like_this),
|
||||
items = similar,
|
||||
onClickItem = { index, item ->
|
||||
position = SIMILAR_ROW
|
||||
onClickItem.invoke(index, item)
|
||||
},
|
||||
onLongClickItem = { index, similar ->
|
||||
position = SIMILAR_ROW
|
||||
onLongClickSimilar.invoke(index, similar)
|
||||
},
|
||||
cardContent = { index, item, mod, onClick, onLongClick ->
|
||||
DiscoverItemCard(
|
||||
item = item,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
showOverlay = false,
|
||||
modifier = mod,
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequesters[SIMILAR_ROW]),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (recommended.isNotEmpty()) {
|
||||
item {
|
||||
ItemRow(
|
||||
title = stringResource(R.string.recommended),
|
||||
items = similar,
|
||||
onClickItem = { index, item ->
|
||||
position = RECOMMENDED_ROW
|
||||
onClickItem.invoke(index, item)
|
||||
},
|
||||
onLongClickItem = { index, similar ->
|
||||
position = RECOMMENDED_ROW
|
||||
onLongClickSimilar.invoke(index, similar)
|
||||
},
|
||||
cardContent = { index, item, mod, onClick, onLongClick ->
|
||||
DiscoverItemCard(
|
||||
item = item,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
showOverlay = true,
|
||||
modifier = mod,
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequesters[RECOMMENDED_ROW]),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TrailerRow(
|
||||
trailers: List<Trailer>,
|
||||
onClickTrailer: (Trailer) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val state = rememberLazyListState()
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.trailers),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
LazyRow(
|
||||
state = state,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRestorer(firstFocus),
|
||||
) {
|
||||
itemsIndexed(trailers) { index, item ->
|
||||
val cardModifier =
|
||||
if (index == 0) {
|
||||
Modifier.focusRequester(firstFocus)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
when (item) {
|
||||
is LocalTrailer -> {
|
||||
SeasonCard(
|
||||
item = item.baseItem,
|
||||
onClick = { onClickTrailer.invoke(item) },
|
||||
onLongClick = {},
|
||||
imageHeight = Cards.height2x3,
|
||||
imageWidth = Dp.Unspecified,
|
||||
showImageOverlay = false,
|
||||
modifier = cardModifier,
|
||||
)
|
||||
}
|
||||
|
||||
is RemoteTrailer -> {
|
||||
val subtitle =
|
||||
when (item.url.toUri().host) {
|
||||
"youtube.com", "www.youtube.com" -> "YouTube"
|
||||
else -> null
|
||||
}
|
||||
SeasonCard(
|
||||
title = item.name,
|
||||
subtitle = subtitle,
|
||||
name = item.name,
|
||||
imageUrl = null,
|
||||
isFavorite = false,
|
||||
isPlayed = false,
|
||||
unplayedItemCount = 0,
|
||||
playedPercentage = 0.0,
|
||||
numberOfVersions = -1,
|
||||
onClick = { onClickTrailer.invoke(item) },
|
||||
onLongClick = {},
|
||||
modifier = cardModifier,
|
||||
showImageOverlay = false,
|
||||
imageHeight = Cards.height2x3,
|
||||
imageWidth = Dp.Unspecified,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
package com.github.damontecres.wholphin.ui.detail.discover
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
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.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
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.api.seerr.model.MovieDetails
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverRating
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.ui.components.GenreText
|
||||
import com.github.damontecres.wholphin.ui.components.OverviewText
|
||||
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||
import com.github.damontecres.wholphin.ui.listToDotString
|
||||
import com.github.damontecres.wholphin.ui.roundMinutes
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Locale
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
@Composable
|
||||
fun DiscoverMovieDetailsHeader(
|
||||
preferences: UserPreferences,
|
||||
movie: MovieDetails,
|
||||
rating: DiscoverRating?,
|
||||
bringIntoViewRequester: BringIntoViewRequester,
|
||||
overviewOnClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
// Title
|
||||
Text(
|
||||
text = movie.title ?: "",
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(.75f),
|
||||
)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.fillMaxWidth(.60f),
|
||||
) {
|
||||
val padding = 4.dp
|
||||
val details =
|
||||
remember(movie) {
|
||||
buildList {
|
||||
movie.releaseDate?.let(::add)
|
||||
movie.runtime
|
||||
?.toDouble()
|
||||
?.minutes
|
||||
?.roundMinutes
|
||||
?.toString()
|
||||
?.let(::add)
|
||||
val release =
|
||||
movie.releases
|
||||
?.results
|
||||
?.firstOrNull { it.iso31661 == Locale.getDefault().country }
|
||||
?: movie.releases
|
||||
?.results
|
||||
?.firstOrNull { it.iso31661 == Locale.US.country }
|
||||
?: movie.releases
|
||||
?.results
|
||||
?.firstOrNull()
|
||||
|
||||
release
|
||||
?.releaseDates
|
||||
?.firstOrNull()
|
||||
?.certification
|
||||
?.let(::add)
|
||||
}.let {
|
||||
listToDotString(
|
||||
it,
|
||||
rating?.audienceRating,
|
||||
rating?.criticRating?.toFloat(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
QuickDetails(details, null)
|
||||
movie.genres?.mapNotNull { it.name }?.letNotEmpty {
|
||||
GenreText(it, Modifier.padding(bottom = padding))
|
||||
}
|
||||
|
||||
movie.tagline?.let { tagline ->
|
||||
Text(
|
||||
text = tagline,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontStyle = FontStyle.Italic,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
|
||||
// Description
|
||||
movie.overview?.let { overview ->
|
||||
OverviewText(
|
||||
overview = overview,
|
||||
maxLines = 3,
|
||||
onClick = overviewOnClick,
|
||||
textBoxHeight = Dp.Unspecified,
|
||||
modifier =
|
||||
Modifier.onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
scope.launch(ExceptionHandler()) {
|
||||
bringIntoViewRequester.bringIntoView()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val directorName =
|
||||
remember(movie.credits?.crew) {
|
||||
movie.credits
|
||||
?.crew
|
||||
?.filter { it.job == "Directing" }
|
||||
?.joinToString(", ") { it.name!! }
|
||||
}
|
||||
|
||||
directorName
|
||||
?.let {
|
||||
Text(
|
||||
text = stringResource(R.string.directed_by, it),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
package com.github.damontecres.wholphin.ui.detail.discover
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.api.seerr.model.MediaRequest
|
||||
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails
|
||||
import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo
|
||||
import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverRating
|
||||
import com.github.damontecres.wholphin.data.model.RemoteTrailer
|
||||
import com.github.damontecres.wholphin.data.model.SeerrPermission
|
||||
import com.github.damontecres.wholphin.data.model.Trailer
|
||||
import com.github.damontecres.wholphin.data.model.hasPermission
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.SeerrServerRepository
|
||||
import com.github.damontecres.wholphin.services.SeerrService
|
||||
import com.github.damontecres.wholphin.services.SeerrUserConfig
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
|
||||
@HiltViewModel(assistedFactory = DiscoverMovieViewModel.Factory::class)
|
||||
class DiscoverMovieViewModel
|
||||
@AssistedInject
|
||||
constructor(
|
||||
private val api: ApiClient,
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val navigationManager: NavigationManager,
|
||||
private val backdropService: BackdropService,
|
||||
val serverRepository: ServerRepository,
|
||||
val seerrService: SeerrService,
|
||||
private val seerrServerRepository: SeerrServerRepository,
|
||||
@Assisted val item: DiscoverItem,
|
||||
) : ViewModel() {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(item: DiscoverItem): DiscoverMovieViewModel
|
||||
}
|
||||
|
||||
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||
val movie = MutableLiveData<MovieDetails?>(null)
|
||||
val rating = MutableLiveData<DiscoverRating?>(null)
|
||||
|
||||
val trailers = MutableLiveData<List<Trailer>>(listOf())
|
||||
val people = MutableLiveData<List<DiscoverItem>>(listOf())
|
||||
val similar = MutableLiveData<List<DiscoverItem>>(listOf())
|
||||
val recommended = MutableLiveData<List<DiscoverItem>>(listOf())
|
||||
val canCancelRequest = MutableStateFlow(false)
|
||||
|
||||
val userConfig = seerrServerRepository.current.map { it?.config }
|
||||
val request4kEnabled = seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false }
|
||||
|
||||
init {
|
||||
init()
|
||||
}
|
||||
|
||||
private fun fetchAndSetItem(): Deferred<MovieDetails> =
|
||||
viewModelScope.async(
|
||||
Dispatchers.IO +
|
||||
LoadingExceptionHandler(
|
||||
loading,
|
||||
"Error fetching movie",
|
||||
),
|
||||
) {
|
||||
val movie = seerrService.api.moviesApi.movieMovieIdGet(movieId = item.id)
|
||||
this@DiscoverMovieViewModel.movie.setValueOnMain(movie)
|
||||
movie
|
||||
}
|
||||
|
||||
fun init(): Job =
|
||||
viewModelScope.launch(
|
||||
Dispatchers.IO +
|
||||
LoadingExceptionHandler(
|
||||
loading,
|
||||
"Error fetching movie",
|
||||
),
|
||||
) {
|
||||
val movie = fetchAndSetItem().await()
|
||||
val discoveredItem = DiscoverItem(movie)
|
||||
backdropService.submit(discoveredItem)
|
||||
|
||||
updateCanCancel()
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
loading.value = LoadingState.Success
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
val result = seerrService.api.moviesApi.movieMovieIdRatingsGet(movieId = item.id)
|
||||
rating.setValueOnMain(DiscoverRating(result))
|
||||
}
|
||||
if (!similar.isInitialized) {
|
||||
viewModelScope.launchIO {
|
||||
val result =
|
||||
seerrService.api.moviesApi
|
||||
.movieMovieIdSimilarGet(movieId = item.id, page = 2)
|
||||
.results
|
||||
?.map(::DiscoverItem)
|
||||
.orEmpty()
|
||||
similar.setValueOnMain(result)
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
val result =
|
||||
seerrService.api.moviesApi
|
||||
.movieMovieIdRecommendationsGet(movieId = item.id, page = 2)
|
||||
.results
|
||||
?.map(::DiscoverItem)
|
||||
.orEmpty()
|
||||
similar.setValueOnMain(result)
|
||||
}
|
||||
}
|
||||
val people =
|
||||
movie.credits
|
||||
?.cast
|
||||
?.map(::DiscoverItem)
|
||||
.orEmpty() +
|
||||
movie.credits
|
||||
?.crew
|
||||
?.map(::DiscoverItem)
|
||||
.orEmpty()
|
||||
this@DiscoverMovieViewModel.people.setValueOnMain(people)
|
||||
val trailers =
|
||||
movie.relatedVideos
|
||||
?.filter { it.type == RelatedVideo.Type.TRAILER }
|
||||
?.filter { it.name.isNotNullOrBlank() && it.url.isNotNullOrBlank() }
|
||||
?.map {
|
||||
RemoteTrailer(it.name!!, it.url!!, null)
|
||||
}.orEmpty()
|
||||
this@DiscoverMovieViewModel.trailers.setValueOnMain(trailers)
|
||||
}
|
||||
|
||||
private suspend fun updateCanCancel() {
|
||||
val user = userConfig.firstOrNull()
|
||||
val canCancel = canUserCancelRequest(user, movie.value?.mediaInfo?.requests)
|
||||
canCancelRequest.update { canCancel }
|
||||
}
|
||||
|
||||
fun navigateTo(destination: Destination) {
|
||||
navigationManager.navigateTo(destination)
|
||||
}
|
||||
|
||||
fun request(
|
||||
id: Int,
|
||||
is4k: Boolean,
|
||||
) {
|
||||
viewModelScope.launchIO {
|
||||
val request =
|
||||
seerrService.api.requestApi.requestPost(
|
||||
RequestPostRequest(
|
||||
is4k = is4k,
|
||||
mediaId = id,
|
||||
mediaType = RequestPostRequest.MediaType.MOVIE,
|
||||
),
|
||||
)
|
||||
fetchAndSetItem().await()
|
||||
updateCanCancel()
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelRequest(id: Int) {
|
||||
viewModelScope.launchIO {
|
||||
movie.value?.mediaInfo?.requests?.firstOrNull()?.let {
|
||||
// TODO handle multiple requests? Or just delete self's request?
|
||||
seerrService.api.requestApi.requestRequestIdDelete(it.id.toString())
|
||||
fetchAndSetItem().await()
|
||||
updateCanCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun canUserCancelRequest(
|
||||
user: SeerrUserConfig?,
|
||||
requests: List<MediaRequest>?,
|
||||
) = (user.hasPermission(SeerrPermission.MANAGE_REQUESTS) && requests?.isNotEmpty() == true) ||
|
||||
(
|
||||
// User requested this
|
||||
user.hasPermission(SeerrPermission.REQUEST) &&
|
||||
requests?.any { it.requestedBy?.id == user?.id } == true
|
||||
)
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
package com.github.damontecres.wholphin.ui.detail.discover
|
||||
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
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.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.SeerrService
|
||||
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.detail.CardGrid
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
@HiltViewModel(assistedFactory = DiscoverPersonViewModel.Factory::class)
|
||||
class DiscoverPersonViewModel
|
||||
@AssistedInject
|
||||
constructor(
|
||||
val navigationManager: NavigationManager,
|
||||
val serverRepository: ServerRepository,
|
||||
val seerrService: SeerrService,
|
||||
private val backdropService: BackdropService,
|
||||
@Assisted val item: DiscoverItem,
|
||||
) : ViewModel() {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(item: DiscoverItem): DiscoverPersonViewModel
|
||||
}
|
||||
|
||||
val credits = MutableStateFlow<DataLoadingState<List<DiscoverItem>>>(DataLoadingState.Pending)
|
||||
|
||||
init {
|
||||
viewModelScope.launchIO {
|
||||
backdropService.clearBackdrop()
|
||||
|
||||
val credits =
|
||||
seerrService.api.personApi
|
||||
.personPersonIdCombinedCreditsGet(personId = item.id)
|
||||
.let { credits ->
|
||||
val cast =
|
||||
credits.cast
|
||||
?.map(::DiscoverItem)
|
||||
.orEmpty()
|
||||
val crew =
|
||||
credits.crew
|
||||
?.map(::DiscoverItem)
|
||||
.orEmpty()
|
||||
cast + crew
|
||||
}
|
||||
this@DiscoverPersonViewModel.credits.update {
|
||||
DataLoadingState.Success(credits)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DiscoverPersonPage(
|
||||
person: DiscoverItem,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: DiscoverPersonViewModel =
|
||||
hiltViewModel<DiscoverPersonViewModel, DiscoverPersonViewModel.Factory>(
|
||||
creationCallback = { it.create(person) },
|
||||
),
|
||||
) {
|
||||
val credits by viewModel.credits.collectAsState()
|
||||
|
||||
when (val state = credits) {
|
||||
is DataLoadingState.Error -> {
|
||||
ErrorMessage(state.message, state.exception, modifier.focusable())
|
||||
}
|
||||
|
||||
DataLoadingState.Loading,
|
||||
DataLoadingState.Pending,
|
||||
-> {
|
||||
LoadingPage(modifier.focusable())
|
||||
}
|
||||
|
||||
is DataLoadingState.Success<List<DiscoverItem>> -> {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
if (state.data.isNotEmpty()) {
|
||||
focusRequester.tryRequestFocus()
|
||||
}
|
||||
}
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = stringResource(R.string.discover) + (person.title?.let { ": $it" } ?: ""),
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (state.data.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.no_results),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else {
|
||||
CardGrid(
|
||||
pager = state.data,
|
||||
onClickItem = { index: Int, item: DiscoverItem ->
|
||||
viewModel.navigationManager.navigateTo(Destination.DiscoveredItem(item))
|
||||
},
|
||||
onLongClickItem = { index: Int, item: DiscoverItem ->
|
||||
},
|
||||
onClickPlay = { _, item ->
|
||||
},
|
||||
letterPosition = { c: Char -> 0 },
|
||||
gridFocusRequester = focusRequester,
|
||||
showJumpButtons = false,
|
||||
showLetterButtons = false,
|
||||
spacing = 16.dp,
|
||||
cardContent = @Composable { item, onClick, onLongClick, mod ->
|
||||
DiscoverItemCard(
|
||||
item = item,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
showOverlay = true,
|
||||
modifier = mod,
|
||||
)
|
||||
},
|
||||
columns = 6,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,551 @@
|
|||
package com.github.damontecres.wholphin.ui.detail.discover
|
||||
|
||||
import android.content.Context
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
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
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.api.seerr.model.Season
|
||||
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverRating
|
||||
import com.github.damontecres.wholphin.data.model.SeerrAvailability
|
||||
import com.github.damontecres.wholphin.data.model.SeerrPermission
|
||||
import com.github.damontecres.wholphin.data.model.Trailer
|
||||
import com.github.damontecres.wholphin.data.model.hasPermission
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.SeerrUserConfig
|
||||
import com.github.damontecres.wholphin.services.TrailerService
|
||||
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
|
||||
import com.github.damontecres.wholphin.ui.cards.DiscoverPersonRow
|
||||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||
import com.github.damontecres.wholphin.ui.components.DialogItem
|
||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.GenreText
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.components.OverviewText
|
||||
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||
import com.github.damontecres.wholphin.ui.listToDotString
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.rememberInt
|
||||
import com.github.damontecres.wholphin.ui.roundMinutes
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
@Composable
|
||||
fun DiscoverSeriesDetails(
|
||||
preferences: UserPreferences,
|
||||
destination: Destination.DiscoveredItem,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: DiscoverSeriesViewModel =
|
||||
hiltViewModel<DiscoverSeriesViewModel, DiscoverSeriesViewModel.Factory>(
|
||||
creationCallback = { it.create(destination.item) },
|
||||
),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||
|
||||
val item by viewModel.tvSeries.observeAsState()
|
||||
val seasons by viewModel.seasons.observeAsState(listOf())
|
||||
val people by viewModel.people.observeAsState(listOf())
|
||||
val similar by viewModel.similar.observeAsState(listOf())
|
||||
val recommended by viewModel.recommended.observeAsState(listOf())
|
||||
val userConfig by viewModel.userConfig.collectAsState(null)
|
||||
val request4kEnabled by viewModel.request4kEnabled.collectAsState(false)
|
||||
val canCancel by viewModel.canCancelRequest.collectAsState()
|
||||
|
||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||
var seasonDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||
|
||||
val requestStr = stringResource(R.string.request)
|
||||
val request4kStr = stringResource(R.string.request_4k)
|
||||
|
||||
when (val state = loading) {
|
||||
is LoadingState.Error -> {
|
||||
ErrorMessage(state)
|
||||
}
|
||||
|
||||
LoadingState.Loading,
|
||||
LoadingState.Pending,
|
||||
-> {
|
||||
LoadingPage()
|
||||
}
|
||||
|
||||
LoadingState.Success -> {
|
||||
item?.let { item ->
|
||||
val rating by viewModel.rating.observeAsState(null)
|
||||
DiscoverSeriesDetailsContent(
|
||||
preferences = preferences,
|
||||
series = item,
|
||||
userConfig = userConfig,
|
||||
rating = rating,
|
||||
canCancel = canCancel,
|
||||
seasons = seasons,
|
||||
people = people,
|
||||
similar = similar,
|
||||
recommended = recommended,
|
||||
modifier = modifier,
|
||||
onClickItem = { index, item ->
|
||||
viewModel.navigateTo(Destination.DiscoveredItem(item))
|
||||
},
|
||||
onClickPerson = {
|
||||
viewModel.navigateTo(Destination.DiscoveredItem(it))
|
||||
},
|
||||
goToOnClick = {
|
||||
item.mediaInfo?.jellyfinMediaId?.toUUIDOrNull()?.let {
|
||||
viewModel.navigateTo(
|
||||
Destination.MediaItem(
|
||||
itemId = it,
|
||||
type = BaseItemKind.MOVIE,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
overviewOnClick = {
|
||||
overviewDialog =
|
||||
ItemDetailsDialogInfo(
|
||||
title = item.name ?: context.getString(R.string.unknown),
|
||||
overview = item.overview,
|
||||
genres = item.genres?.mapNotNull { it.name }.orEmpty(),
|
||||
files = listOf(),
|
||||
)
|
||||
},
|
||||
trailerOnClick = {
|
||||
TrailerService.onClick(context, it, viewModel::navigateTo)
|
||||
},
|
||||
trailers = listOf(),
|
||||
requestOnClick = {
|
||||
item.id?.let { id ->
|
||||
if (request4kEnabled) {
|
||||
moreDialog =
|
||||
DialogParams(
|
||||
fromLongClick = false,
|
||||
title = item.name ?: "",
|
||||
items =
|
||||
listOf(
|
||||
DialogItem(
|
||||
text = requestStr,
|
||||
onClick = { viewModel.request(id, false) },
|
||||
),
|
||||
DialogItem(
|
||||
text = request4kStr,
|
||||
onClick = { viewModel.request(id, true) },
|
||||
),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
viewModel.request(id, false)
|
||||
}
|
||||
}
|
||||
},
|
||||
cancelOnClick = {
|
||||
item.id?.let { viewModel.cancelRequest(it) }
|
||||
},
|
||||
moreOnClick = {
|
||||
},
|
||||
onLongClickPerson = { _, _ -> },
|
||||
onLongClickSimilar = { _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
overviewDialog?.let { info ->
|
||||
ItemDetailsDialog(
|
||||
info = info,
|
||||
showFilePath = false,
|
||||
onDismissRequest = { overviewDialog = null },
|
||||
)
|
||||
}
|
||||
seasonDialog?.let { params ->
|
||||
DialogPopup(
|
||||
showDialog = true,
|
||||
title = params.title,
|
||||
dialogItems = params.items,
|
||||
waitToLoad = params.fromLongClick,
|
||||
onDismissRequest = { seasonDialog = null },
|
||||
)
|
||||
}
|
||||
moreDialog?.let { params ->
|
||||
DialogPopup(
|
||||
showDialog = true,
|
||||
title = params.title,
|
||||
dialogItems = params.items,
|
||||
onDismissRequest = { moreDialog = null },
|
||||
dismissOnClick = true,
|
||||
waitToLoad = params.fromLongClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val HEADER_ROW = 0
|
||||
private const val SEASONS_ROW = HEADER_ROW + 1
|
||||
private const val PEOPLE_ROW = SEASONS_ROW + 1
|
||||
private const val EXTRAS_ROW = PEOPLE_ROW + 1
|
||||
private const val SIMILAR_ROW = EXTRAS_ROW + 1
|
||||
private const val RECOMMENDED_ROW = SIMILAR_ROW + 1
|
||||
|
||||
@Composable
|
||||
fun DiscoverSeriesDetailsContent(
|
||||
preferences: UserPreferences,
|
||||
userConfig: SeerrUserConfig?,
|
||||
series: TvDetails,
|
||||
rating: DiscoverRating?,
|
||||
canCancel: Boolean,
|
||||
seasons: List<Season>,
|
||||
similar: List<DiscoverItem>,
|
||||
recommended: List<DiscoverItem>,
|
||||
trailers: List<Trailer>,
|
||||
people: List<DiscoverItem>,
|
||||
requestOnClick: () -> Unit,
|
||||
cancelOnClick: () -> Unit,
|
||||
trailerOnClick: (Trailer) -> Unit,
|
||||
overviewOnClick: () -> Unit,
|
||||
goToOnClick: () -> Unit,
|
||||
moreOnClick: () -> Unit,
|
||||
onClickItem: (Int, DiscoverItem) -> Unit,
|
||||
onClickPerson: (DiscoverItem) -> Unit,
|
||||
onLongClickPerson: (Int, DiscoverItem) -> Unit,
|
||||
onLongClickSimilar: (Int, DiscoverItem) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||
|
||||
var position by rememberInt()
|
||||
val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
|
||||
val playFocusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequesters.getOrNull(position)?.tryRequestFocus()
|
||||
}
|
||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||
|
||||
Box(
|
||||
modifier = modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(16.dp)
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(bottom = 80.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
modifier = Modifier,
|
||||
) {
|
||||
item {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.bringIntoViewRequester(bringIntoViewRequester),
|
||||
) {
|
||||
DiscoverSeriesDetailsHeader(
|
||||
series = series,
|
||||
rating = rating,
|
||||
overviewOnClick = overviewOnClick,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 32.dp, bottom = 16.dp),
|
||||
)
|
||||
ExpandableDiscoverButtons(
|
||||
availability =
|
||||
SeerrAvailability.from(series.mediaInfo?.status)
|
||||
?: SeerrAvailability.UNKNOWN,
|
||||
requestOnClick = requestOnClick,
|
||||
cancelOnClick = cancelOnClick,
|
||||
moreOnClick = moreOnClick,
|
||||
goToOnClick = goToOnClick,
|
||||
buttonOnFocusChanged = {
|
||||
if (it.isFocused) {
|
||||
position = HEADER_ROW
|
||||
scope.launch(ExceptionHandler()) {
|
||||
bringIntoViewRequester.bringIntoView()
|
||||
}
|
||||
}
|
||||
},
|
||||
canRequest = userConfig.hasPermission(SeerrPermission.REQUEST),
|
||||
canCancel = canCancel,
|
||||
trailers = trailers,
|
||||
trailerOnClick = trailerOnClick,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp)
|
||||
.focusRequester(focusRequesters[HEADER_ROW]),
|
||||
)
|
||||
}
|
||||
}
|
||||
// item {
|
||||
// ItemRow(
|
||||
// title = stringResource(R.string.tv_seasons) + " (${seasons.size})",
|
||||
// items = seasons,
|
||||
// onClickItem = { index, item ->
|
||||
// position = SEASONS_ROW
|
||||
// // onClickItem.invoke(index, item)
|
||||
// },
|
||||
// onLongClickItem = { index, item ->
|
||||
// position = SEASONS_ROW
|
||||
// },
|
||||
// modifier =
|
||||
// Modifier
|
||||
// .fillMaxWidth()
|
||||
// .focusRequester(focusRequesters[SEASONS_ROW]),
|
||||
// cardContent = @Composable { index, item, mod, onClick, onLongClick ->
|
||||
// SeasonCard(
|
||||
// item = item,
|
||||
// onClick = onClick,
|
||||
// onLongClick = onLongClick,
|
||||
// imageHeight = Cards.height2x3,
|
||||
// imageWidth = Dp.Unspecified,
|
||||
// showImageOverlay = true,
|
||||
// modifier = mod,
|
||||
// )
|
||||
// },
|
||||
// )
|
||||
// }
|
||||
if (people.isNotEmpty()) {
|
||||
item {
|
||||
DiscoverPersonRow(
|
||||
people = people,
|
||||
onClick = {
|
||||
position = PEOPLE_ROW
|
||||
onClickPerson.invoke(it)
|
||||
},
|
||||
onLongClick = { index, person ->
|
||||
position = PEOPLE_ROW
|
||||
onLongClickPerson.invoke(index, person)
|
||||
},
|
||||
modifier = Modifier.focusRequester(focusRequesters[PEOPLE_ROW]),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (similar.isNotEmpty()) {
|
||||
item {
|
||||
ItemRow(
|
||||
title = stringResource(R.string.more_like_this),
|
||||
items = similar,
|
||||
onClickItem = { index, item ->
|
||||
position = SIMILAR_ROW
|
||||
onClickItem.invoke(index, item)
|
||||
},
|
||||
onLongClickItem = { index, similar ->
|
||||
position = SIMILAR_ROW
|
||||
onLongClickSimilar.invoke(index, similar)
|
||||
},
|
||||
cardContent = { index, item, mod, onClick, onLongClick ->
|
||||
DiscoverItemCard(
|
||||
item = item,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
showOverlay = false,
|
||||
modifier = mod,
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequesters[SIMILAR_ROW]),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (recommended.isNotEmpty()) {
|
||||
item {
|
||||
ItemRow(
|
||||
title = stringResource(R.string.recommended),
|
||||
items = similar,
|
||||
onClickItem = { index, item ->
|
||||
position = RECOMMENDED_ROW
|
||||
onClickItem.invoke(index, item)
|
||||
},
|
||||
onLongClickItem = { index, similar ->
|
||||
position = RECOMMENDED_ROW
|
||||
onLongClickSimilar.invoke(index, similar)
|
||||
},
|
||||
cardContent = { index, item, mod, onClick, onLongClick ->
|
||||
DiscoverItemCard(
|
||||
item = item,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
showOverlay = true,
|
||||
modifier = mod,
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequesters[RECOMMENDED_ROW]),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
moreDialog?.let { params ->
|
||||
DialogPopup(
|
||||
showDialog = true,
|
||||
title = params.title,
|
||||
dialogItems = params.items,
|
||||
onDismissRequest = { moreDialog = null },
|
||||
dismissOnClick = true,
|
||||
waitToLoad = params.fromLongClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DiscoverSeriesDetailsHeader(
|
||||
series: TvDetails,
|
||||
rating: DiscoverRating?,
|
||||
overviewOnClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(
|
||||
text = series.name ?: stringResource(R.string.unknown),
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(.75f),
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.fillMaxWidth(.60f),
|
||||
) {
|
||||
val padding = 4.dp
|
||||
val details =
|
||||
remember(series) {
|
||||
buildList {
|
||||
series.firstAirDate?.let(::add)
|
||||
series.episodeRunTime
|
||||
?.average()
|
||||
?.takeIf { !it.isNaN() && it > 0 }
|
||||
?.minutes
|
||||
?.roundMinutes
|
||||
?.toString()
|
||||
?.let(::add)
|
||||
// TODO
|
||||
}.let {
|
||||
listToDotString(
|
||||
it,
|
||||
rating?.audienceRating,
|
||||
rating?.criticRating?.toFloat(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
QuickDetails(details, null)
|
||||
series.genres?.mapNotNull { it.name }?.letNotEmpty {
|
||||
GenreText(it, Modifier.padding(bottom = padding))
|
||||
}
|
||||
series.overview?.let { overview ->
|
||||
OverviewText(
|
||||
overview = overview,
|
||||
maxLines = 3,
|
||||
onClick = overviewOnClick,
|
||||
textBoxHeight = Dp.Unspecified,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun buildDialogForSeason(
|
||||
context: Context,
|
||||
s: BaseItem,
|
||||
onClickItem: (BaseItem) -> Unit,
|
||||
markPlayed: (Boolean) -> Unit,
|
||||
onClickPlay: (Boolean) -> Unit,
|
||||
): DialogParams {
|
||||
val items =
|
||||
buildList {
|
||||
add(
|
||||
DialogItem(context.getString(R.string.go_to), Icons.Default.PlayArrow) {
|
||||
onClickItem.invoke(s)
|
||||
},
|
||||
)
|
||||
if (s.data.userData?.played == true) {
|
||||
add(
|
||||
DialogItem(context.getString(R.string.mark_unwatched), R.string.fa_eye) {
|
||||
markPlayed.invoke(false)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
add(
|
||||
DialogItem(context.getString(R.string.mark_watched), R.string.fa_eye_slash) {
|
||||
markPlayed.invoke(true)
|
||||
},
|
||||
)
|
||||
}
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.play),
|
||||
Icons.Default.PlayArrow,
|
||||
iconColor = Color.Green.copy(alpha = .8f),
|
||||
) {
|
||||
onClickPlay.invoke(false)
|
||||
},
|
||||
)
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.shuffle),
|
||||
R.string.fa_shuffle,
|
||||
) {
|
||||
onClickPlay.invoke(true)
|
||||
},
|
||||
)
|
||||
}
|
||||
return DialogParams(
|
||||
title = s.name ?: context.getString(R.string.tv_season),
|
||||
fromLongClick = true,
|
||||
items = items,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
package com.github.damontecres.wholphin.ui.detail.discover
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest
|
||||
import com.github.damontecres.wholphin.api.seerr.model.Season
|
||||
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverRating
|
||||
import com.github.damontecres.wholphin.data.model.Trailer
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.SeerrServerRepository
|
||||
import com.github.damontecres.wholphin.services.SeerrService
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
|
||||
@HiltViewModel(assistedFactory = DiscoverSeriesViewModel.Factory::class)
|
||||
class DiscoverSeriesViewModel
|
||||
@AssistedInject
|
||||
constructor(
|
||||
private val api: ApiClient,
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val navigationManager: NavigationManager,
|
||||
private val backdropService: BackdropService,
|
||||
val serverRepository: ServerRepository,
|
||||
val seerrService: SeerrService,
|
||||
private val seerrServerRepository: SeerrServerRepository,
|
||||
@Assisted val item: DiscoverItem,
|
||||
) : ViewModel() {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(item: DiscoverItem): DiscoverSeriesViewModel
|
||||
}
|
||||
|
||||
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||
val tvSeries = MutableLiveData<TvDetails?>(null)
|
||||
val rating = MutableLiveData<DiscoverRating?>(null)
|
||||
|
||||
val seasons = MutableLiveData<List<Season>>(listOf())
|
||||
val trailers = MutableLiveData<List<Trailer>>(listOf())
|
||||
val people = MutableLiveData<List<DiscoverItem>>(listOf())
|
||||
val similar = MutableLiveData<List<DiscoverItem>>(listOf())
|
||||
val recommended = MutableLiveData<List<DiscoverItem>>(listOf())
|
||||
val canCancelRequest = MutableStateFlow(false)
|
||||
|
||||
val userConfig = seerrServerRepository.current.map { it?.config }
|
||||
val request4kEnabled = seerrServerRepository.current.map { it?.request4kTvEnabled ?: false }
|
||||
|
||||
init {
|
||||
init()
|
||||
}
|
||||
|
||||
private fun fetchAndSetItem(): Deferred<TvDetails> =
|
||||
viewModelScope.async(
|
||||
Dispatchers.IO +
|
||||
LoadingExceptionHandler(
|
||||
loading,
|
||||
"Error fetching movie",
|
||||
),
|
||||
) {
|
||||
val tv = seerrService.api.tvApi.tvTvIdGet(tvId = item.id)
|
||||
this@DiscoverSeriesViewModel.tvSeries.setValueOnMain(tv)
|
||||
tv
|
||||
}
|
||||
|
||||
fun init(): Job =
|
||||
viewModelScope.launch(
|
||||
Dispatchers.IO +
|
||||
LoadingExceptionHandler(
|
||||
loading,
|
||||
"Error fetching movie",
|
||||
),
|
||||
) {
|
||||
val tv = fetchAndSetItem().await()
|
||||
val discoveredItem = DiscoverItem(tv)
|
||||
backdropService.submit(discoveredItem)
|
||||
|
||||
updateCanCancel()
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
loading.value = LoadingState.Success
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
val result = seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id)
|
||||
rating.setValueOnMain(DiscoverRating(result))
|
||||
}
|
||||
if (!similar.isInitialized) {
|
||||
viewModelScope.launchIO {
|
||||
val result =
|
||||
seerrService.api.moviesApi
|
||||
.movieMovieIdSimilarGet(movieId = item.id, page = 2)
|
||||
.results
|
||||
?.map(::DiscoverItem)
|
||||
.orEmpty()
|
||||
similar.setValueOnMain(result)
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
val result =
|
||||
seerrService.api.moviesApi
|
||||
.movieMovieIdRecommendationsGet(movieId = item.id, page = 2)
|
||||
.results
|
||||
?.map(::DiscoverItem)
|
||||
.orEmpty()
|
||||
similar.setValueOnMain(result)
|
||||
}
|
||||
}
|
||||
val people =
|
||||
tv.credits
|
||||
?.cast
|
||||
?.map(::DiscoverItem)
|
||||
.orEmpty() +
|
||||
tv.credits
|
||||
?.crew
|
||||
?.map(::DiscoverItem)
|
||||
.orEmpty()
|
||||
this@DiscoverSeriesViewModel.people.setValueOnMain(people)
|
||||
}
|
||||
|
||||
fun navigateTo(destination: Destination) {
|
||||
navigationManager.navigateTo(destination)
|
||||
}
|
||||
|
||||
private suspend fun updateCanCancel() {
|
||||
val user = userConfig.firstOrNull()
|
||||
val canCancel = canUserCancelRequest(user, tvSeries.value?.mediaInfo?.requests)
|
||||
canCancelRequest.update { canCancel }
|
||||
}
|
||||
|
||||
fun request(
|
||||
id: Int,
|
||||
is4k: Boolean,
|
||||
) {
|
||||
viewModelScope.launchIO {
|
||||
val request =
|
||||
seerrService.api.requestApi.requestPost(
|
||||
RequestPostRequest(
|
||||
is4k = is4k,
|
||||
mediaId = id,
|
||||
mediaType = RequestPostRequest.MediaType.TV,
|
||||
seasons = RequestPostRequest.Seasons.ALL, // TODO handle picking seasons
|
||||
),
|
||||
)
|
||||
fetchAndSetItem().await()
|
||||
updateCanCancel()
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelRequest(id: Int) {
|
||||
viewModelScope.launchIO {
|
||||
tvSeries.value?.mediaInfo?.requests?.firstOrNull()?.let {
|
||||
// TODO handle multiple requests? Or just delete self's request?
|
||||
seerrService.api.requestApi.requestRequestIdDelete(it.id.toString())
|
||||
fetchAndSetItem().await()
|
||||
updateCanCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
package com.github.damontecres.wholphin.ui.detail.discover
|
||||
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.FocusState
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.SeerrAvailability
|
||||
import com.github.damontecres.wholphin.data.model.Trailer
|
||||
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
|
||||
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
|
||||
import com.github.damontecres.wholphin.ui.components.TrailerButton
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import kotlin.time.Duration
|
||||
|
||||
@Composable
|
||||
fun ExpandableDiscoverButtons(
|
||||
canRequest: Boolean,
|
||||
canCancel: Boolean,
|
||||
availability: SeerrAvailability,
|
||||
trailers: List<Trailer>?,
|
||||
requestOnClick: () -> Unit,
|
||||
cancelOnClick: () -> Unit,
|
||||
goToOnClick: () -> Unit,
|
||||
moreOnClick: () -> Unit,
|
||||
trailerOnClick: (Trailer) -> Unit,
|
||||
buttonOnFocusChanged: (FocusState) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
contentPadding = PaddingValues(8.dp),
|
||||
modifier =
|
||||
modifier
|
||||
.focusGroup()
|
||||
.focusRestorer(firstFocus),
|
||||
) {
|
||||
val text =
|
||||
when (availability) {
|
||||
SeerrAvailability.UNKNOWN -> R.string.request
|
||||
|
||||
SeerrAvailability.PENDING,
|
||||
SeerrAvailability.PROCESSING,
|
||||
-> R.string.pending
|
||||
|
||||
SeerrAvailability.PARTIALLY_AVAILABLE,
|
||||
SeerrAvailability.AVAILABLE,
|
||||
-> R.string.go_to
|
||||
|
||||
SeerrAvailability.DELETED -> R.string.delete // TODO
|
||||
}
|
||||
val icon =
|
||||
when (availability) {
|
||||
SeerrAvailability.UNKNOWN -> R.string.fa_download
|
||||
|
||||
SeerrAvailability.PENDING,
|
||||
SeerrAvailability.PROCESSING,
|
||||
-> R.string.fa_clock
|
||||
|
||||
SeerrAvailability.PARTIALLY_AVAILABLE,
|
||||
SeerrAvailability.AVAILABLE,
|
||||
-> R.string.fa_play
|
||||
|
||||
SeerrAvailability.DELETED -> R.string.fa_video // TODO
|
||||
}
|
||||
item("first") {
|
||||
ExpandableFaButton(
|
||||
title = text,
|
||||
iconStringRes = icon,
|
||||
enabled = if (availability == SeerrAvailability.UNKNOWN) canRequest else true,
|
||||
onClick = {
|
||||
when (availability) {
|
||||
SeerrAvailability.UNKNOWN -> {
|
||||
requestOnClick.invoke()
|
||||
}
|
||||
|
||||
SeerrAvailability.PENDING,
|
||||
SeerrAvailability.PROCESSING,
|
||||
-> {
|
||||
// TODO?
|
||||
}
|
||||
|
||||
SeerrAvailability.PARTIALLY_AVAILABLE,
|
||||
SeerrAvailability.AVAILABLE,
|
||||
-> {
|
||||
goToOnClick.invoke()
|
||||
}
|
||||
|
||||
SeerrAvailability.DELETED -> {
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.focusRequester(firstFocus)
|
||||
.onFocusChanged(buttonOnFocusChanged),
|
||||
)
|
||||
}
|
||||
|
||||
if (canCancel) {
|
||||
item("cancel") {
|
||||
ExpandablePlayButton(
|
||||
title = R.string.cancel,
|
||||
icon = Icons.Default.Delete,
|
||||
onClick = {
|
||||
firstFocus.tryRequestFocus()
|
||||
cancelOnClick.invoke()
|
||||
},
|
||||
resume = Duration.ZERO,
|
||||
enabled = canCancel,
|
||||
modifier =
|
||||
Modifier
|
||||
.onFocusChanged(buttonOnFocusChanged),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (trailers != null) {
|
||||
item("trailers") {
|
||||
TrailerButton(
|
||||
trailers = trailers,
|
||||
trailerOnClick = trailerOnClick,
|
||||
modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// More button
|
||||
// No functionality yet
|
||||
// item("more") {
|
||||
// ExpandablePlayButton(
|
||||
// R.string.more,
|
||||
// Duration.ZERO,
|
||||
// Icons.Default.MoreVert,
|
||||
// { moreOnClick.invoke() },
|
||||
// Modifier
|
||||
// .onFocusChanged(buttonOnFocusChanged),
|
||||
// )
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,6 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
|
@ -26,11 +25,11 @@ import androidx.compose.ui.res.stringResource
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
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.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
|
||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
|
|
@ -48,10 +47,10 @@ import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
|||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.rememberInt
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import org.jellyfin.sdk.model.serializer.toUUID
|
||||
|
|
@ -113,12 +112,12 @@ fun EpisodeDetails(
|
|||
|
||||
LoadingState.Success -> {
|
||||
item?.let { ep ->
|
||||
LifecycleStartEffect(destination.itemId) {
|
||||
LifecycleResumeEffect(destination.itemId) {
|
||||
viewModel.maybePlayThemeSong(
|
||||
destination.itemId,
|
||||
preferences.appPreferences.interfacePreferences.playThemeSongs,
|
||||
)
|
||||
onStopOrDispose {
|
||||
onPauseOrDispose {
|
||||
viewModel.release()
|
||||
}
|
||||
}
|
||||
|
|
@ -131,7 +130,6 @@ fun EpisodeDetails(
|
|||
Destination.Playback(
|
||||
ep.id,
|
||||
it.inWholeMilliseconds,
|
||||
ep,
|
||||
),
|
||||
)
|
||||
},
|
||||
|
|
@ -157,6 +155,7 @@ fun EpisodeDetails(
|
|||
favorite = ep.data.userData?.isFavorite ?: false,
|
||||
seriesId = ep.data.seriesId,
|
||||
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
|
||||
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
|
||||
actions = moreActions,
|
||||
onChooseVersion = {
|
||||
chooseVersion =
|
||||
|
|
@ -182,6 +181,12 @@ fun EpisodeDetails(
|
|||
chooseStream(
|
||||
context = context,
|
||||
streams = source.mediaStreams.orEmpty(),
|
||||
currentIndex =
|
||||
if (type == MediaStreamType.AUDIO) {
|
||||
chosenStreams?.audioStream?.index
|
||||
} else {
|
||||
chosenStreams?.subtitleStream?.index
|
||||
},
|
||||
type = type,
|
||||
onClick = { trackIndex ->
|
||||
viewModel.saveTrackSelection(
|
||||
|
|
@ -206,6 +211,9 @@ fun EpisodeDetails(
|
|||
)
|
||||
}
|
||||
},
|
||||
onClearChosenStreams = {
|
||||
viewModel.clearChosenStreams(chosenStreams)
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
|
|
@ -291,9 +299,7 @@ fun EpisodeDetailsContent(
|
|||
val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO
|
||||
|
||||
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequesters.getOrNull(position)?.tryRequestFocus()
|
||||
}
|
||||
RequestOrRestoreFocus(focusRequesters.getOrNull(position))
|
||||
Box(modifier = modifier) {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
|
|
@ -338,6 +344,8 @@ fun EpisodeDetailsContent(
|
|||
}
|
||||
}
|
||||
},
|
||||
trailers = null,
|
||||
trailerOnClick = {},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@ import com.github.damontecres.wholphin.data.ChosenStreams
|
|||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.ui.components.EpisodeName
|
||||
import com.github.damontecres.wholphin.ui.components.EpisodeQuickDetails
|
||||
import com.github.damontecres.wholphin.ui.components.OverviewText
|
||||
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
||||
import com.github.damontecres.wholphin.ui.components.SeriesName
|
||||
import com.github.damontecres.wholphin.ui.components.VideoStreamDetails
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
|
|
@ -55,10 +55,11 @@ fun EpisodeDetailsHeader(
|
|||
modifier = Modifier.fillMaxWidth(.60f),
|
||||
) {
|
||||
val padding = 8.dp
|
||||
EpisodeQuickDetails(dto)
|
||||
QuickDetails(ep.ui.quickDetails, ep.timeRemainingOrRuntime)
|
||||
|
||||
VideoStreamDetails(
|
||||
chosenStreams = chosenStreams,
|
||||
numberOfVersions = dto.mediaSourceCount ?: 0,
|
||||
modifier = Modifier.padding(bottom = padding),
|
||||
)
|
||||
dto.taglines?.firstOrNull()?.let { tagline ->
|
||||
|
|
|
|||
|
|
@ -182,4 +182,19 @@ class EpisodeViewModel
|
|||
release()
|
||||
navigationManager.navigateTo(destination)
|
||||
}
|
||||
|
||||
fun clearChosenStreams(chosenStreams: ChosenStreams?) {
|
||||
viewModelScope.launchIO {
|
||||
itemPlaybackRepository.deleteChosenStreams(chosenStreams)
|
||||
item.value?.let { item ->
|
||||
val result =
|
||||
itemPlaybackRepository.getSelectedTracks(
|
||||
itemId,
|
||||
item,
|
||||
userPreferencesService.getCurrent(),
|
||||
)
|
||||
this@EpisodeViewModel.chosenStreams.setValueOnMain(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,6 +114,7 @@ class DvrScheduleViewModel
|
|||
@Composable
|
||||
fun DvrSchedule(
|
||||
requestFocusAfterLoading: Boolean,
|
||||
focusRequesterOnEmpty: FocusRequester,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: DvrScheduleViewModel = hiltViewModel(),
|
||||
) {
|
||||
|
|
@ -138,7 +139,13 @@ fun DvrSchedule(
|
|||
var showDialog by remember { mutableStateOf<BaseItem?>(null) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
if (requestFocusAfterLoading) {
|
||||
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||
LaunchedEffect(Unit) {
|
||||
if (active.isNotEmpty() || recordings.isNotEmpty()) {
|
||||
focusRequester.tryRequestFocus()
|
||||
} else {
|
||||
focusRequesterOnEmpty.tryRequestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
DvrScheduleContent(
|
||||
activeRecordings = active,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
package com.github.damontecres.wholphin.ui.detail.livetv
|
||||
|
||||
import android.content.Context
|
||||
import android.text.format.DateUtils
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
|
@ -15,8 +18,10 @@ import com.github.damontecres.wholphin.services.NavigationManager
|
|||
import com.github.damontecres.wholphin.ui.AppColors
|
||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode
|
||||
import com.github.damontecres.wholphin.ui.dot
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.roundMinutes
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
|
|
@ -50,6 +55,7 @@ import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest
|
|||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import timber.log.Timber
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
|
@ -58,6 +64,7 @@ import kotlin.time.Duration
|
|||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.toKotlinDuration
|
||||
|
||||
const val MAX_HOURS = 48L
|
||||
|
||||
|
|
@ -528,6 +535,7 @@ data class TvChannel(
|
|||
val imageUrl: String?,
|
||||
)
|
||||
|
||||
@Stable
|
||||
data class TvProgram(
|
||||
val id: UUID,
|
||||
val channelId: UUID,
|
||||
|
|
@ -548,6 +556,49 @@ data class TvProgram(
|
|||
) {
|
||||
val isFake = category == ProgramCategory.FAKE
|
||||
|
||||
val quickDetails by lazy {
|
||||
val now = LocalDateTime.now()
|
||||
buildAnnotatedString {
|
||||
val differentDay = start.toLocalDate() != now.toLocalDate()
|
||||
val time =
|
||||
DateUtils.formatDateRange(
|
||||
WholphinApplication.instance,
|
||||
start
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.epochSecond * 1000,
|
||||
end
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.epochSecond * 1000,
|
||||
DateUtils.FORMAT_SHOW_TIME or if (differentDay) DateUtils.FORMAT_SHOW_WEEKDAY else 0,
|
||||
)
|
||||
append(time)
|
||||
dot()
|
||||
|
||||
if (!isFake) {
|
||||
duration
|
||||
.roundMinutes
|
||||
.toString()
|
||||
.let(::append)
|
||||
dot()
|
||||
if (now.isAfter(start) && now.isBefore(end)) {
|
||||
java.time.Duration
|
||||
.between(now, end)
|
||||
.toKotlinDuration()
|
||||
.roundMinutes
|
||||
.let { append("$it left") }
|
||||
dot()
|
||||
}
|
||||
seasonEpisode?.let { "S${it.season} E${it.episode}" }?.let {
|
||||
append(it)
|
||||
dot()
|
||||
}
|
||||
officialRating?.let(::append)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val NO_DATA = WholphinApplication.instance.getString(R.string.no_data)
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ 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.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
|
|
@ -56,7 +57,6 @@ 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
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import eu.wewox.programguide.ProgramGuide
|
||||
import eu.wewox.programguide.ProgramGuideDimensions
|
||||
|
|
@ -289,22 +289,8 @@ fun TvGuideGridContent(
|
|||
|
||||
var focusedItem by rememberPosition(RowColumn(0, 0))
|
||||
val focusedChannelIndex = focusedItem.row
|
||||
val focusedProgramIndex =
|
||||
remember(programs.range, focusedItem) {
|
||||
focusedItem.let { focus ->
|
||||
(programs.range.first..<focus.row).sumOf {
|
||||
val channelId = channels[it].id
|
||||
channelProgramCount[channelId] ?: 0
|
||||
} + focus.column
|
||||
}
|
||||
}
|
||||
var focusedProgramIndex by remember { mutableIntStateOf(0) }
|
||||
|
||||
LaunchedEffect(focusedProgramIndex) {
|
||||
// Timber.v("Focusing on $focusedItem, programIndex=$focusedProgramIndex")
|
||||
scope.launch(ExceptionHandler()) {
|
||||
state.animateToProgram(focusedProgramIndex, Alignment.Center)
|
||||
}
|
||||
}
|
||||
var gridHasFocus by rememberSaveable { mutableStateOf(false) }
|
||||
var channelColumnFocused by rememberSaveable { mutableStateOf(false) }
|
||||
Box(modifier = modifier) {
|
||||
|
|
@ -485,7 +471,7 @@ fun TvGuideGridContent(
|
|||
|
||||
if (newFocusedItem != null) {
|
||||
val channel = channels[newFocusedItem.row]
|
||||
val programs = programs.programsByChannel[channel.id].orEmpty()
|
||||
val channelPrograms = programs.programsByChannel[channel.id].orEmpty()
|
||||
// Ensure it isn't going out of range
|
||||
val toFocus =
|
||||
newFocusedItem
|
||||
|
|
@ -494,10 +480,24 @@ fun TvGuideGridContent(
|
|||
column =
|
||||
newFocusedItem.column.coerceIn(
|
||||
0,
|
||||
(programs.size - 1).coerceAtLeast(0),
|
||||
(channelPrograms.size - 1).coerceAtLeast(0),
|
||||
),
|
||||
)
|
||||
focusedItem = toFocus
|
||||
focusedProgramIndex =
|
||||
toFocus.let { focus ->
|
||||
(programs.range.first..<focus.row).sumOf {
|
||||
val channelId = channels[it].id
|
||||
channelProgramCount[channelId] ?: 0
|
||||
} + focus.column
|
||||
}
|
||||
scope.launch {
|
||||
try {
|
||||
state.animateToProgram(focusedProgramIndex, Alignment.Center)
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Couldn't scroll to $focusedProgramIndex")
|
||||
}
|
||||
}
|
||||
onFocus(toFocus)
|
||||
return@onPreviewKeyEvent true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,12 @@
|
|||
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
|
||||
|
|
@ -17,59 +14,15 @@ 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.QuickDetails
|
||||
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,
|
||||
|
|
@ -100,13 +53,7 @@ fun TvGuideHeader(
|
|||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
DotSeparatedRow(
|
||||
texts = details,
|
||||
communityRating = null,
|
||||
criticRating = null,
|
||||
textStyle = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier,
|
||||
)
|
||||
program?.quickDetails?.let { QuickDetails(it, null) }
|
||||
if (program?.isRepeat == true) {
|
||||
StreamLabel(stringResource(R.string.live_tv_repeat))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,13 +8,10 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||
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,31 +21,26 @@ import androidx.compose.runtime.setValue
|
|||
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.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.compose.LifecycleStartEffect
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.ChosenStreams
|
||||
import com.github.damontecres.wholphin.data.ExtrasItem
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.Chapter
|
||||
import com.github.damontecres.wholphin.data.model.LocalTrailer
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||
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.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.TrailerService
|
||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||
import com.github.damontecres.wholphin.ui.Cards
|
||||
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
|
||||
import com.github.damontecres.wholphin.ui.cards.ChapterRow
|
||||
import com.github.damontecres.wholphin.ui.cards.ExtrasRow
|
||||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||
|
|
@ -71,13 +63,16 @@ import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
|||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
|
||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson
|
||||
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
|
||||
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.rememberInt
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import org.jellyfin.sdk.model.serializer.toUUID
|
||||
|
|
@ -109,6 +104,7 @@ fun MovieDetails(
|
|||
val similar by viewModel.similar.observeAsState(listOf())
|
||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||
val chosenStreams by viewModel.chosenStreams.observeAsState(null)
|
||||
val discovered by viewModel.discovered.collectAsState()
|
||||
|
||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||
|
|
@ -144,12 +140,12 @@ fun MovieDetails(
|
|||
|
||||
LoadingState.Success -> {
|
||||
item?.let { movie ->
|
||||
LifecycleStartEffect(destination.itemId) {
|
||||
LifecycleResumeEffect(destination.itemId) {
|
||||
viewModel.maybePlayThemeSong(
|
||||
destination.itemId,
|
||||
preferences.appPreferences.interfacePreferences.playThemeSongs,
|
||||
)
|
||||
onStopOrDispose {
|
||||
onPauseOrDispose {
|
||||
viewModel.release()
|
||||
}
|
||||
}
|
||||
|
|
@ -178,7 +174,6 @@ fun MovieDetails(
|
|||
Destination.Playback(
|
||||
movie.id,
|
||||
it.inWholeMilliseconds,
|
||||
movie,
|
||||
),
|
||||
)
|
||||
},
|
||||
|
|
@ -204,6 +199,7 @@ fun MovieDetails(
|
|||
favorite = movie.data.userData?.isFavorite ?: false,
|
||||
seriesId = null,
|
||||
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
|
||||
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
|
||||
actions = moreActions,
|
||||
onChooseVersion = {
|
||||
chooseVersion =
|
||||
|
|
@ -220,6 +216,7 @@ fun MovieDetails(
|
|||
moreDialog = null
|
||||
},
|
||||
onChooseTracks = { type ->
|
||||
|
||||
viewModel.streamChoiceService
|
||||
.chooseSource(
|
||||
movie.data,
|
||||
|
|
@ -230,6 +227,12 @@ fun MovieDetails(
|
|||
context = context,
|
||||
streams = source.mediaStreams.orEmpty(),
|
||||
type = type,
|
||||
currentIndex =
|
||||
if (type == MediaStreamType.AUDIO) {
|
||||
chosenStreams?.audioStream?.index
|
||||
} else {
|
||||
chosenStreams?.subtitleStream?.index
|
||||
},
|
||||
onClick = { trackIndex ->
|
||||
viewModel.saveTrackSelection(
|
||||
movie,
|
||||
|
|
@ -252,6 +255,9 @@ fun MovieDetails(
|
|||
files = movie.data.mediaSources.orEmpty(),
|
||||
)
|
||||
},
|
||||
onClearChosenStreams = {
|
||||
viewModel.clearChosenStreams(chosenStreams)
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
|
|
@ -299,6 +305,10 @@ fun MovieDetails(
|
|||
onClickExtra = { index, extra ->
|
||||
viewModel.navigateTo(extra.destination)
|
||||
},
|
||||
discovered = discovered,
|
||||
onClickDiscover = { index, item ->
|
||||
viewModel.navigateTo(item.destination)
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
|
@ -359,6 +369,7 @@ private const val TRAILER_ROW = PEOPLE_ROW + 1
|
|||
private const val CHAPTER_ROW = TRAILER_ROW + 1
|
||||
private const val EXTRAS_ROW = CHAPTER_ROW + 1
|
||||
private const val SIMILAR_ROW = EXTRAS_ROW + 1
|
||||
private const val DISCOVER_ROW = SIMILAR_ROW + 1
|
||||
|
||||
@Composable
|
||||
fun MovieDetailsContent(
|
||||
|
|
@ -370,6 +381,7 @@ fun MovieDetailsContent(
|
|||
trailers: List<Trailer>,
|
||||
extras: List<ExtrasItem>,
|
||||
similar: List<BaseItem>,
|
||||
discovered: List<DiscoverItem>,
|
||||
playOnClick: (Duration) -> Unit,
|
||||
trailerOnClick: (Trailer) -> Unit,
|
||||
overviewOnClick: () -> Unit,
|
||||
|
|
@ -381,19 +393,20 @@ fun MovieDetailsContent(
|
|||
onLongClickPerson: (Int, Person) -> Unit,
|
||||
onLongClickSimilar: (Int, BaseItem) -> Unit,
|
||||
onClickExtra: (Int, ExtrasItem) -> Unit,
|
||||
onClickDiscover: (Int, DiscoverItem) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var position by rememberInt(0)
|
||||
val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
|
||||
val focusRequesters = remember { List(DISCOVER_ROW + 1) { FocusRequester() } }
|
||||
val dto = movie.data
|
||||
val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO
|
||||
|
||||
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequesters.getOrNull(position)?.tryRequestFocus()
|
||||
}
|
||||
|
||||
RequestOrRestoreFocus(focusRequesters.getOrNull(position))
|
||||
|
||||
Box(modifier = modifier) {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
|
|
@ -438,6 +451,11 @@ fun MovieDetailsContent(
|
|||
}
|
||||
}
|
||||
},
|
||||
trailers = trailers,
|
||||
trailerOnClick = {
|
||||
position = TRAILER_ROW
|
||||
trailerOnClick.invoke(it)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -465,21 +483,6 @@ fun MovieDetailsContent(
|
|||
)
|
||||
}
|
||||
}
|
||||
if (trailers.isNotEmpty()) {
|
||||
item {
|
||||
TrailerRow(
|
||||
trailers = trailers,
|
||||
onClickTrailer = {
|
||||
position = TRAILER_ROW
|
||||
trailerOnClick.invoke(it)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequesters[TRAILER_ROW]),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (chapters.isNotEmpty()) {
|
||||
item {
|
||||
ChapterRow(
|
||||
|
|
@ -515,6 +518,14 @@ fun MovieDetailsContent(
|
|||
}
|
||||
if (similar.isNotEmpty()) {
|
||||
item {
|
||||
val imageHeight =
|
||||
remember(movie.type) {
|
||||
if (movie.type == BaseItemKind.MOVIE) {
|
||||
Cards.height2x3
|
||||
} else {
|
||||
Cards.heightEpisode
|
||||
}
|
||||
}
|
||||
ItemRow(
|
||||
title = stringResource(R.string.more_like_this),
|
||||
items = similar,
|
||||
|
|
@ -533,7 +544,7 @@ fun MovieDetailsContent(
|
|||
onLongClick = onLongClick,
|
||||
modifier = mod,
|
||||
showImageOverlay = true,
|
||||
imageHeight = Cards.height2x3,
|
||||
imageHeight = imageHeight,
|
||||
imageWidth = Dp.Unspecified,
|
||||
)
|
||||
},
|
||||
|
|
@ -544,79 +555,22 @@ fun MovieDetailsContent(
|
|||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TrailerRow(
|
||||
trailers: List<Trailer>,
|
||||
onClickTrailer: (Trailer) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val state = rememberLazyListState()
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.trailers),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
LazyRow(
|
||||
state = state,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRestorer(firstFocus),
|
||||
) {
|
||||
itemsIndexed(trailers) { index, item ->
|
||||
val cardModifier =
|
||||
if (index == 0) {
|
||||
Modifier.focusRequester(firstFocus)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
when (item) {
|
||||
is LocalTrailer -> {
|
||||
SeasonCard(
|
||||
item = item.baseItem,
|
||||
onClick = { onClickTrailer.invoke(item) },
|
||||
onLongClick = {},
|
||||
imageHeight = Cards.height2x3,
|
||||
imageWidth = Dp.Unspecified,
|
||||
showImageOverlay = false,
|
||||
modifier = cardModifier,
|
||||
)
|
||||
}
|
||||
|
||||
is RemoteTrailer -> {
|
||||
val subtitle =
|
||||
when (item.url.toUri().host) {
|
||||
"youtube.com", "www.youtube.com" -> "YouTube"
|
||||
else -> null
|
||||
}
|
||||
SeasonCard(
|
||||
title = item.name,
|
||||
subtitle = subtitle,
|
||||
name = item.name,
|
||||
imageUrl = null,
|
||||
isFavorite = false,
|
||||
isPlayed = false,
|
||||
unplayedItemCount = 0,
|
||||
playedPercentage = 0.0,
|
||||
onClick = { onClickTrailer.invoke(item) },
|
||||
onLongClick = {},
|
||||
modifier = cardModifier,
|
||||
showImageOverlay = false,
|
||||
imageHeight = Cards.height2x3,
|
||||
imageWidth = Dp.Unspecified,
|
||||
)
|
||||
}
|
||||
if (discovered.isNotEmpty()) {
|
||||
item {
|
||||
DiscoverRow(
|
||||
row =
|
||||
DiscoverRowData(
|
||||
stringResource(R.string.discover),
|
||||
DataLoadingState.Success(discovered),
|
||||
),
|
||||
onClickItem = { index: Int, item: DiscoverItem ->
|
||||
position = DISCOVER_ROW
|
||||
onClickDiscover.invoke(index, item)
|
||||
},
|
||||
onLongClickItem = { _, _ -> },
|
||||
onCardFocus = {},
|
||||
focusRequester = focusRequesters[DISCOVER_ROW],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue