mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +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: |
|
path: |
|
||||||
app/libs
|
app/libs
|
||||||
key: ${{ runner.os }}-ffmpeg-${{ hashFiles('**/libs.versions.toml', '**/scripts/ffmpeg/build_ffmpeg_decoder.sh') }}
|
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
|
- name: Build ffmpeg decoder
|
||||||
id: ffmpeg-decoder
|
id: ffmpeg-decoder
|
||||||
if: steps.cache_ffmpeg_module.outputs.cache-hit != 'true'
|
if: steps.cache_ffmpeg_module.outputs.cache-hit != 'true'
|
||||||
|
|
@ -30,23 +49,7 @@ runs:
|
||||||
app/libs
|
app/libs
|
||||||
key: ${{ steps.cache_ffmpeg_module.outputs.cache-primary-key }}
|
key: ${{ steps.cache_ffmpeg_module.outputs.cache-primary-key }}
|
||||||
|
|
||||||
- name: Load libmpv module cache
|
# libmpv
|
||||||
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
|
|
||||||
- name: Get libmpv dependencies
|
- name: Get libmpv dependencies
|
||||||
if: steps.cache_libmpv_module.outputs.cache-hit != 'true'
|
if: steps.cache_libmpv_module.outputs.cache-hit != 'true'
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
|
||||||
2
.github/workflows/pr.yml
vendored
2
.github/workflows/pr.yml
vendored
|
|
@ -43,7 +43,7 @@ jobs:
|
||||||
- name: Build app
|
- name: Build app
|
||||||
id: buildapp
|
id: buildapp
|
||||||
run: |
|
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/,$//')
|
apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//')
|
||||||
echo "apks=$apks" >> "$GITHUB_OUTPUT"
|
echo "apks=$apks" >> "$GITHUB_OUTPUT"
|
||||||
- name: Tar build dirs
|
- name: Tar build dirs
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,13 @@ plugins {
|
||||||
alias(libs.plugins.protobuf)
|
alias(libs.plugins.protobuf)
|
||||||
alias(libs.plugins.kotlin.plugin.serialization)
|
alias(libs.plugins.kotlin.plugin.serialization)
|
||||||
alias(libs.plugins.aboutLibraries)
|
alias(libs.plugins.aboutLibraries)
|
||||||
|
alias(libs.plugins.openapi.generator)
|
||||||
}
|
}
|
||||||
|
|
||||||
val isCI = if (System.getenv("CI") != null) System.getenv("CI").toBoolean() else false
|
val isCI = if (System.getenv("CI") != null) System.getenv("CI").toBoolean() else false
|
||||||
val shouldSign = isCI && System.getenv("KEY_ALIAS") != null
|
val shouldSign = isCI && System.getenv("KEY_ALIAS") != null
|
||||||
val ffmpegModuleExists = project.file("libs/lib-decoder-ffmpeg-release.aar").exists()
|
val ffmpegModuleExists = project.file("libs/lib-decoder-ffmpeg-release.aar").exists()
|
||||||
|
val av1ModuleExists = project.file("libs/lib-decoder-av1-release.aar").exists()
|
||||||
|
|
||||||
val gitTags =
|
val gitTags =
|
||||||
providers
|
providers
|
||||||
|
|
@ -145,6 +147,12 @@ android {
|
||||||
isUniversalApk = true
|
isUniversalApk = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
getByName("main") {
|
||||||
|
kotlin.srcDirs("$buildDir/generated/seerr_api/src/main/kotlin")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protobuf {
|
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 {
|
dependencies {
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.appcompat)
|
implementation(libs.androidx.appcompat)
|
||||||
|
|
@ -191,10 +226,15 @@ dependencies {
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(libs.androidx.datastore)
|
implementation(libs.androidx.datastore)
|
||||||
implementation(libs.protobuf.kotlin.lite)
|
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.exoplayer)
|
||||||
|
implementation(libs.androidx.media3.session)
|
||||||
implementation(libs.androidx.media3.datasource.okhttp)
|
implementation(libs.androidx.media3.datasource.okhttp)
|
||||||
implementation(libs.androidx.media3.exoplayer.hls)
|
implementation(libs.androidx.media3.exoplayer.hls)
|
||||||
|
implementation(libs.androidx.media3.exoplayer.dash)
|
||||||
implementation(libs.androidx.media3.ui)
|
implementation(libs.androidx.media3.ui)
|
||||||
implementation(libs.androidx.media3.ui.compose)
|
implementation(libs.androidx.media3.ui.compose)
|
||||||
implementation(libs.ass.media)
|
implementation(libs.ass.media)
|
||||||
|
|
@ -228,6 +268,7 @@ dependencies {
|
||||||
implementation(libs.androidx.palette.ktx)
|
implementation(libs.androidx.palette.ktx)
|
||||||
ksp(libs.androidx.room.compiler)
|
ksp(libs.androidx.room.compiler)
|
||||||
ksp(libs.hilt.android.compiler)
|
ksp(libs.hilt.android.compiler)
|
||||||
|
ksp(libs.androidx.hilt.compiler)
|
||||||
|
|
||||||
implementation(libs.timber)
|
implementation(libs.timber)
|
||||||
implementation(libs.slf4j2.timber)
|
implementation(libs.slf4j2.timber)
|
||||||
|
|
@ -241,6 +282,8 @@ dependencies {
|
||||||
implementation(libs.acra.limiter)
|
implementation(libs.acra.limiter)
|
||||||
compileOnly(libs.auto.service.annotations)
|
compileOnly(libs.auto.service.annotations)
|
||||||
ksp(libs.auto.service.ksp)
|
ksp(libs.auto.service.ksp)
|
||||||
|
implementation(platform(libs.okhttp.bom))
|
||||||
|
implementation(libs.okhttp)
|
||||||
|
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
|
|
@ -250,4 +293,11 @@ dependencies {
|
||||||
if (ffmpegModuleExists || isCI) {
|
if (ffmpegModuleExists || isCI) {
|
||||||
implementation(files("libs/lib-decoder-ffmpeg-release.aar"))
|
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"?>
|
<?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.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
@ -11,6 +12,7 @@
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="28" />
|
android:maxSdkVersion="28" />
|
||||||
|
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
|
||||||
|
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.hardware.touchscreen"
|
android:name="android.hardware.touchscreen"
|
||||||
|
|
@ -22,6 +24,13 @@
|
||||||
android:name="android.hardware.microphone"
|
android:name="android.hardware.microphone"
|
||||||
android:required="false" />
|
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
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:banner="@mipmap/ic_banner"
|
android:banner="@mipmap/ic_banner"
|
||||||
|
|
@ -30,12 +39,13 @@
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Wholphin"
|
android:theme="@style/Theme.Wholphin"
|
||||||
android:name=".WholphinApplication"
|
android:name=".WholphinApplication"
|
||||||
android:usesCleartextTraffic="true">
|
android:usesCleartextTraffic="true"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout">
|
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
|
@ -53,6 +63,10 @@
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
android:resource="@xml/provider_paths" />
|
android:resource="@xml/provider_paths" />
|
||||||
</provider>
|
</provider>
|
||||||
|
<provider
|
||||||
|
android:name="androidx.startup.InitializationProvider"
|
||||||
|
android:authorities="${applicationId}.androidx-startup"
|
||||||
|
tools:node="remove" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
package com.github.damontecres.wholphin
|
package com.github.damontecres.wholphin
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.res.Configuration
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
|
|
@ -13,13 +15,17 @@ import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
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.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.AppUpgradeHandler
|
import com.github.damontecres.wholphin.services.AppUpgradeHandler
|
||||||
import com.github.damontecres.wholphin.services.BackdropService
|
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.DeviceProfileService
|
||||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver
|
import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver
|
||||||
import com.github.damontecres.wholphin.services.RefreshRateService
|
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.SetupDestination
|
||||||
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
||||||
import com.github.damontecres.wholphin.services.UpdateChecker
|
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.hilt.AuthOkHttpClient
|
||||||
|
import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService
|
||||||
import com.github.damontecres.wholphin.ui.CoilConfig
|
import com.github.damontecres.wholphin.ui.CoilConfig
|
||||||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
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.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.nav.ApplicationContent
|
import com.github.damontecres.wholphin.ui.nav.ApplicationContent
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
|
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
|
||||||
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
|
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
|
||||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
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 com.github.damontecres.wholphin.util.DebugLogTree
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -96,6 +106,20 @@ class MainActivity : AppCompatActivity() {
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var refreshRateService: RefreshRateService
|
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
|
private var signInAuto = true
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
|
|
@ -106,15 +130,16 @@ class MainActivity : AppCompatActivity() {
|
||||||
if (savedInstanceState == null) {
|
if (savedInstanceState == null) {
|
||||||
appUpgradeHandler.copySubfont(false)
|
appUpgradeHandler.copySubfont(false)
|
||||||
}
|
}
|
||||||
refreshRateService.refreshRateMode.observe(this) { mode ->
|
refreshRateService.refreshRateMode.observe(this) { modeId ->
|
||||||
// Listen for refresh rate changes
|
// Listen for refresh rate changes
|
||||||
val attrs = window.attributes
|
val attrs = window.attributes
|
||||||
if (attrs.preferredDisplayModeId != mode.modeId) {
|
if (attrs.preferredDisplayModeId != modeId) {
|
||||||
Timber.d("Switch preferredRefreshRate to %s", mode.refreshRate)
|
Timber.d("Switch preferredDisplayModeId to %s", modeId)
|
||||||
window.attributes = attrs.apply { preferredRefreshRate = mode.refreshRate }
|
window.attributes = attrs.apply { preferredDisplayModeId = modeId }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewModel.appStart()
|
viewModel.appStart()
|
||||||
|
val requestedDestination = this.intent?.let(::extractDestination)
|
||||||
setContent {
|
setContent {
|
||||||
val appPreferences by userPreferencesDataStore.data.collectAsState(null)
|
val appPreferences by userPreferencesDataStore.data.collectAsState(null)
|
||||||
appPreferences?.let { appPreferences ->
|
appPreferences?.let { appPreferences ->
|
||||||
|
|
@ -211,13 +236,37 @@ class MainActivity : AppCompatActivity() {
|
||||||
remember(appPreferences) {
|
remember(appPreferences) {
|
||||||
UserPreferences(appPreferences)
|
UserPreferences(appPreferences)
|
||||||
}
|
}
|
||||||
|
var showContent by remember {
|
||||||
|
mutableStateOf(true)
|
||||||
|
}
|
||||||
|
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
|
||||||
|
if (!preferences.appPreferences.signInAutomatically) {
|
||||||
|
showContent = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showContent) {
|
||||||
ApplicationContent(
|
ApplicationContent(
|
||||||
user = current.user,
|
user = current.user,
|
||||||
server = current.server,
|
server = current.server,
|
||||||
|
startDestination =
|
||||||
|
requestedDestination
|
||||||
|
?: Destination.Home(),
|
||||||
navigationManager = navigationManager,
|
navigationManager = navigationManager,
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
modifier = Modifier.fillMaxSize(),
|
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() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
Timber.i("onResume")
|
Timber.d("onResume")
|
||||||
lifecycleScope.launchIO {
|
lifecycleScope.launchIO {
|
||||||
appUpgradeHandler.run()
|
appUpgradeHandler.run()
|
||||||
}
|
}
|
||||||
|
|
@ -241,7 +290,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
override fun onRestart() {
|
override fun onRestart() {
|
||||||
super.onRestart()
|
super.onRestart()
|
||||||
Timber.i("onRestart")
|
Timber.d("onRestart")
|
||||||
viewModel.appStart()
|
viewModel.appStart()
|
||||||
// val signInAutomatically =
|
// val signInAutomatically =
|
||||||
// runBlocking { userPreferencesDataStore.data.firstOrNull()?.signInAutomatically } ?: true
|
// runBlocking { userPreferencesDataStore.data.firstOrNull()?.signInAutomatically } ?: true
|
||||||
|
|
@ -253,35 +302,89 @@ class MainActivity : AppCompatActivity() {
|
||||||
// }
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
// override fun onStop() {
|
override fun onStop() {
|
||||||
// super.onStop()
|
super.onStop()
|
||||||
// Timber.i("onStop")
|
Timber.d("onStop")
|
||||||
// }
|
tvProviderSchedulerService.launchOneTimeRefresh()
|
||||||
//
|
}
|
||||||
// override fun onPause() {
|
|
||||||
// super.onPause()
|
override fun onPause() {
|
||||||
// Timber.i("onPause")
|
super.onPause()
|
||||||
// }
|
Timber.d("onPause")
|
||||||
//
|
}
|
||||||
// override fun onStart() {
|
|
||||||
// super.onStart()
|
override fun onStart() {
|
||||||
// Timber.i("onStart")
|
super.onStart()
|
||||||
// }
|
Timber.d("onStart")
|
||||||
//
|
}
|
||||||
// override fun onSaveInstanceState(outState: Bundle) {
|
|
||||||
// super.onSaveInstanceState(outState)
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
// Timber.i("onSaveInstanceState")
|
super.onSaveInstanceState(outState)
|
||||||
// }
|
Timber.d("onSaveInstanceState")
|
||||||
//
|
}
|
||||||
// override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
|
||||||
// super.onRestoreInstanceState(savedInstanceState)
|
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||||
// Timber.i("onRestoreInstanceState")
|
super.onRestoreInstanceState(savedInstanceState)
|
||||||
// }
|
Timber.d("onRestoreInstanceState")
|
||||||
//
|
}
|
||||||
// override fun onDestroy() {
|
|
||||||
// super.onDestroy()
|
override fun onDestroy() {
|
||||||
// Timber.i("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
|
@HiltViewModel
|
||||||
|
|
@ -295,16 +398,16 @@ class MainActivityViewModel
|
||||||
private val backdropService: BackdropService,
|
private val backdropService: BackdropService,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
fun appStart() {
|
fun appStart() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launchIO {
|
||||||
val prefs = preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
|
try {
|
||||||
|
val prefs =
|
||||||
|
preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
|
||||||
if (prefs.signInAutomatically) {
|
if (prefs.signInAutomatically) {
|
||||||
val current =
|
val current =
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
serverRepository.restoreSession(
|
serverRepository.restoreSession(
|
||||||
prefs.currentServerId?.toUUIDOrNull(),
|
prefs.currentServerId?.toUUIDOrNull(),
|
||||||
prefs.currentUserId?.toUUIDOrNull(),
|
prefs.currentUserId?.toUUIDOrNull(),
|
||||||
)
|
)
|
||||||
}
|
|
||||||
if (current != null) {
|
if (current != null) {
|
||||||
// Restored
|
// Restored
|
||||||
navigationManager.navigateTo(SetupDestination.AppContent(current))
|
navigationManager.navigateTo(SetupDestination.AppContent(current))
|
||||||
|
|
@ -318,9 +421,7 @@ class MainActivityViewModel
|
||||||
val currentServerId = prefs.currentServerId?.toUUIDOrNull()
|
val currentServerId = prefs.currentServerId?.toUUIDOrNull()
|
||||||
if (currentServerId != null) {
|
if (currentServerId != null) {
|
||||||
val currentServer =
|
val currentServer =
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
serverRepository.serverDao.getServer(currentServerId)?.server
|
serverRepository.serverDao.getServer(currentServerId)?.server
|
||||||
}
|
|
||||||
if (currentServer != null) {
|
if (currentServer != null) {
|
||||||
navigationManager.navigateTo(SetupDestination.UserList(currentServer))
|
navigationManager.navigateTo(SetupDestination.UserList(currentServer))
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -330,6 +431,10 @@ class MainActivityViewModel
|
||||||
navigationManager.navigateTo(SetupDestination.ServerList)
|
navigationManager.navigateTo(SetupDestination.ServerList)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Error during appStart")
|
||||||
|
navigationManager.navigateTo(SetupDestination.ServerList)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
// Create the mediaCodecCapabilitiesTest if needed
|
// Create the mediaCodecCapabilitiesTest if needed
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import android.os.StrictMode.ThreadPolicy
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.runtime.Composer
|
import androidx.compose.runtime.Composer
|
||||||
import androidx.compose.runtime.ExperimentalComposeRuntimeApi
|
import androidx.compose.runtime.ExperimentalComposeRuntimeApi
|
||||||
|
import androidx.hilt.work.HiltWorkerFactory
|
||||||
|
import androidx.work.Configuration
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import org.acra.ACRA
|
import org.acra.ACRA
|
||||||
import org.acra.ReportField
|
import org.acra.ReportField
|
||||||
|
|
@ -14,10 +16,13 @@ import org.acra.config.dialog
|
||||||
import org.acra.data.StringFormat
|
import org.acra.data.StringFormat
|
||||||
import org.acra.ktx.initAcra
|
import org.acra.ktx.initAcra
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
@OptIn(ExperimentalComposeRuntimeApi::class)
|
@OptIn(ExperimentalComposeRuntimeApi::class)
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
class WholphinApplication : Application() {
|
class WholphinApplication :
|
||||||
|
Application(),
|
||||||
|
Configuration.Provider {
|
||||||
init {
|
init {
|
||||||
instance = this
|
instance = this
|
||||||
|
|
||||||
|
|
@ -94,6 +99,16 @@ class WholphinApplication : Application() {
|
||||||
ACRA.errorReporter.putCustomData("SDK_INT", Build.VERSION.SDK_INT.toString())
|
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 {
|
companion object {
|
||||||
lateinit var instance: WholphinApplication
|
lateinit var instance: WholphinApplication
|
||||||
private set
|
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.LibraryDisplayInfo
|
||||||
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
|
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
|
||||||
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
|
import com.github.damontecres.wholphin.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 com.github.damontecres.wholphin.ui.components.ViewOptions
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
|
|
@ -32,8 +34,10 @@ import java.util.UUID
|
||||||
LibraryDisplayInfo::class,
|
LibraryDisplayInfo::class,
|
||||||
PlaybackLanguageChoice::class,
|
PlaybackLanguageChoice::class,
|
||||||
ItemTrackModification::class,
|
ItemTrackModification::class,
|
||||||
|
SeerrServer::class,
|
||||||
|
SeerrUser::class,
|
||||||
],
|
],
|
||||||
version = 12,
|
version = 20,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
autoMigrations = [
|
autoMigrations = [
|
||||||
AutoMigration(3, 4),
|
AutoMigration(3, 4),
|
||||||
|
|
@ -45,6 +49,7 @@ import java.util.UUID
|
||||||
AutoMigration(9, 10),
|
AutoMigration(9, 10),
|
||||||
AutoMigration(10, 11),
|
AutoMigration(10, 11),
|
||||||
AutoMigration(11, 12),
|
AutoMigration(11, 12),
|
||||||
|
AutoMigration(12, 20),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@TypeConverters(Converters::class)
|
@TypeConverters(Converters::class)
|
||||||
|
|
@ -58,6 +63,8 @@ abstract class AppDatabase : RoomDatabase() {
|
||||||
abstract fun libraryDisplayInfoDao(): LibraryDisplayInfoDao
|
abstract fun libraryDisplayInfoDao(): LibraryDisplayInfoDao
|
||||||
|
|
||||||
abstract fun playbackLanguageChoiceDao(): PlaybackLanguageChoiceDao
|
abstract fun playbackLanguageChoiceDao(): PlaybackLanguageChoiceDao
|
||||||
|
|
||||||
|
abstract fun seerrServerDao(): SeerrServerDao
|
||||||
}
|
}
|
||||||
|
|
||||||
class Converters {
|
class Converters {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.github.damontecres.wholphin.data
|
package com.github.damontecres.wholphin.data
|
||||||
|
|
||||||
import androidx.room.Dao
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Delete
|
||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
|
|
@ -11,22 +12,25 @@ import java.util.UUID
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface ItemPlaybackDao {
|
interface ItemPlaybackDao {
|
||||||
fun getItem(
|
suspend fun getItem(
|
||||||
user: JellyfinUser,
|
user: JellyfinUser,
|
||||||
itemId: UUID,
|
itemId: UUID,
|
||||||
): ItemPlayback? = getItem(user.rowId, itemId)
|
): ItemPlayback? = getItem(user.rowId, itemId)
|
||||||
|
|
||||||
@Query("SELECT * from ItemPlayback WHERE userId=:userId AND itemId=:itemId")
|
@Query("SELECT * from ItemPlayback WHERE userId=:userId AND itemId=:itemId")
|
||||||
fun getItem(
|
suspend fun getItem(
|
||||||
userId: Int,
|
userId: Int,
|
||||||
itemId: UUID,
|
itemId: UUID,
|
||||||
): ItemPlayback?
|
): ItemPlayback?
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@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")
|
@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")
|
@Query("SELECT * FROM ItemTrackModification WHERE userId=:userId AND itemId=:itemId")
|
||||||
suspend fun getTrackModifications(
|
suspend fun getTrackModifications(
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ class ItemPlaybackRepository
|
||||||
constructor(
|
constructor(
|
||||||
val serverRepository: ServerRepository,
|
val serverRepository: ServerRepository,
|
||||||
val itemPlaybackDao: ItemPlaybackDao,
|
val itemPlaybackDao: ItemPlaybackDao,
|
||||||
|
private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
|
||||||
private val streamChoiceService: StreamChoiceService,
|
private val streamChoiceService: StreamChoiceService,
|
||||||
) {
|
) {
|
||||||
suspend fun getSelectedTracks(
|
suspend fun getSelectedTracks(
|
||||||
|
|
@ -61,7 +62,7 @@ class ItemPlaybackRepository
|
||||||
)
|
)
|
||||||
val subtitleStream =
|
val subtitleStream =
|
||||||
streamChoiceService.chooseSubtitleStream(
|
streamChoiceService.chooseSubtitleStream(
|
||||||
audioStream = audioStream,
|
audioStreamLang = audioStream?.language,
|
||||||
candidates =
|
candidates =
|
||||||
source.mediaStreams
|
source.mediaStreams
|
||||||
?.filter { it.type == MediaStreamType.SUBTITLE }
|
?.filter { it.type == MediaStreamType.SUBTITLE }
|
||||||
|
|
@ -132,7 +133,7 @@ class ItemPlaybackRepository
|
||||||
Timber.v("Saving track selection %s", toSave)
|
Timber.v("Saving track selection %s", toSave)
|
||||||
toSave = saveItemPlayback(toSave)
|
toSave = saveItemPlayback(toSave)
|
||||||
val seriesId = item.data.seriesId
|
val seriesId = item.data.seriesId
|
||||||
if (seriesId != null && trackIndex != TrackIndex.UNSPECIFIED) {
|
if (seriesId != null && (trackIndex >= 0 || trackIndex == TrackIndex.DISABLED)) {
|
||||||
if (type == MediaStreamType.AUDIO) {
|
if (type == MediaStreamType.AUDIO) {
|
||||||
val stream = source.mediaStreams?.first { it.index == trackIndex }
|
val stream = source.mediaStreams?.first { it.index == trackIndex }
|
||||||
if (stream?.language != null) {
|
if (stream?.language != null) {
|
||||||
|
|
@ -146,7 +147,7 @@ class ItemPlaybackRepository
|
||||||
subtitlesDisabled = true,
|
subtitlesDisabled = true,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
val stream = source.mediaStreams?.first { it.index == trackIndex }
|
val stream = source.mediaStreams?.firstOrNull { it.index == trackIndex }
|
||||||
if (stream?.language != null) {
|
if (stream?.language != null) {
|
||||||
streamChoiceService.updateSubtitles(
|
streamChoiceService.updateSubtitles(
|
||||||
item.data,
|
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(
|
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.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
|
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
|
||||||
import com.github.damontecres.wholphin.data.model.NavPinType
|
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.Destination
|
||||||
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
|
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
|
||||||
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
|
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
|
||||||
import com.github.damontecres.wholphin.util.supportedCollectionTypes
|
import com.github.damontecres.wholphin.util.supportedCollectionTypes
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||||
|
|
@ -24,6 +26,7 @@ class NavDrawerItemRepository
|
||||||
private val api: ApiClient,
|
private val api: ApiClient,
|
||||||
private val serverRepository: ServerRepository,
|
private val serverRepository: ServerRepository,
|
||||||
private val serverPreferencesDao: ServerPreferencesDao,
|
private val serverPreferencesDao: ServerPreferencesDao,
|
||||||
|
private val seerrServerRepository: SeerrServerRepository,
|
||||||
) {
|
) {
|
||||||
suspend fun getNavDrawerItems(): List<NavDrawerItem> {
|
suspend fun getNavDrawerItems(): List<NavDrawerItem> {
|
||||||
val user = serverRepository.currentUser.value
|
val user = serverRepository.currentUser.value
|
||||||
|
|
@ -46,7 +49,13 @@ class NavDrawerItemRepository
|
||||||
setOf()
|
setOf()
|
||||||
}
|
}
|
||||||
|
|
||||||
val builtins = listOf(NavDrawerItem.Favorites)
|
val builtins =
|
||||||
|
if (seerrServerRepository.active.first()) {
|
||||||
|
listOf(NavDrawerItem.Favorites, NavDrawerItem.Discover)
|
||||||
|
} else {
|
||||||
|
listOf(NavDrawerItem.Favorites)
|
||||||
|
}
|
||||||
|
|
||||||
val libraries =
|
val libraries =
|
||||||
userViews
|
userViews
|
||||||
.filter { it.collectionType in supportedCollectionTypes || it.id in recordingFolders }
|
.filter { it.collectionType in supportedCollectionTypes || it.id in recordingFolders }
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.github.damontecres.wholphin.data
|
package com.github.damontecres.wholphin.data
|
||||||
|
|
||||||
import androidx.room.Dao
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Delete
|
||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
|
|
@ -17,4 +18,7 @@ interface PlaybackLanguageChoiceDao {
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun save(plc: PlaybackLanguageChoice): Long
|
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() {
|
suspend fun switchServerOrUser() {
|
||||||
apiClient.update(baseUrl = null, accessToken = null)
|
|
||||||
userPreferencesDataStore.updateData {
|
userPreferencesDataStore.updateData {
|
||||||
it
|
it
|
||||||
.toBuilder()
|
.toBuilder()
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,40 @@
|
||||||
package com.github.damontecres.wholphin.data.model
|
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.CardGridItem
|
||||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
||||||
|
import com.github.damontecres.wholphin.ui.dot
|
||||||
import com.github.damontecres.wholphin.ui.formatDateTime
|
import com.github.damontecres.wholphin.ui.formatDateTime
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.playback.playable
|
import com.github.damontecres.wholphin.ui.playback.playable
|
||||||
|
import com.github.damontecres.wholphin.ui.roundMinutes
|
||||||
import com.github.damontecres.wholphin.ui.seasonEpisode
|
import com.github.damontecres.wholphin.ui.seasonEpisode
|
||||||
import com.github.damontecres.wholphin.ui.seasonEpisodePadded
|
import com.github.damontecres.wholphin.ui.seasonEpisodePadded
|
||||||
import com.github.damontecres.wholphin.ui.seriesProductionYears
|
import com.github.damontecres.wholphin.ui.seriesProductionYears
|
||||||
|
import com.github.damontecres.wholphin.ui.timeRemaining
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.Transient
|
import kotlinx.serialization.Transient
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
|
import java.util.Locale
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@Stable
|
||||||
data class BaseItem(
|
data class BaseItem(
|
||||||
val data: BaseItemDto,
|
val data: BaseItemDto,
|
||||||
val useSeriesForPrimary: Boolean,
|
val useSeriesForPrimary: Boolean,
|
||||||
) : CardGridItem {
|
) : CardGridItem {
|
||||||
override val id get() = data.id
|
val id get() = data.id
|
||||||
|
|
||||||
|
override val gridId get() = id.toString()
|
||||||
|
|
||||||
override val playable: Boolean
|
override val playable: Boolean
|
||||||
get() = type.playable
|
get() = type.playable
|
||||||
|
|
@ -69,6 +82,61 @@ data class BaseItem(
|
||||||
|
|
||||||
val favorite get() = data.userData?.isFavorite ?: false
|
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? =
|
private fun dateAsIndex(): Int? =
|
||||||
data.premiereDate
|
data.premiereDate
|
||||||
?.let {
|
?.let {
|
||||||
|
|
@ -86,23 +154,21 @@ data class BaseItem(
|
||||||
Destination.SeriesOverview(
|
Destination.SeriesOverview(
|
||||||
data.seriesId!!,
|
data.seriesId!!,
|
||||||
BaseItemKind.SERIES,
|
BaseItemKind.SERIES,
|
||||||
this,
|
|
||||||
SeasonEpisodeIds(seasonId, data.parentIndexNumber, id, indexNumber),
|
SeasonEpisodeIds(seasonId, data.parentIndexNumber, id, indexNumber),
|
||||||
)
|
)
|
||||||
} ?: Destination.MediaItem(id, type, this)
|
} ?: Destination.MediaItem(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
BaseItemKind.SEASON -> {
|
BaseItemKind.SEASON -> {
|
||||||
Destination.SeriesOverview(
|
Destination.SeriesOverview(
|
||||||
data.seriesId!!,
|
data.seriesId!!,
|
||||||
BaseItemKind.SERIES,
|
BaseItemKind.SERIES,
|
||||||
this,
|
|
||||||
SeasonEpisodeIds(id, indexNumber, null, null),
|
SeasonEpisodeIds(id, indexNumber, null, null),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
Destination.MediaItem(id, type, this)
|
Destination.MediaItem(this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|
@ -122,3 +188,8 @@ data class BaseItem(
|
||||||
}
|
}
|
||||||
|
|
||||||
val BaseItemDto.aspectRatioFloat: Float? get() = width?.let { w -> height?.let { h -> w.toFloat() / h.toFloat() } }
|
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)
|
* The user has explicitly disabled the tracks (eg turned off subtitles)
|
||||||
*/
|
*/
|
||||||
const val DISABLED = -2
|
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
|
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.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||||
import org.jellyfin.sdk.model.UUID
|
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.ImageType
|
||||||
import org.jellyfin.sdk.model.api.PersonKind
|
import org.jellyfin.sdk.model.api.PersonKind
|
||||||
|
|
||||||
|
@Stable
|
||||||
data class Person(
|
data class Person(
|
||||||
val id: UUID,
|
val id: UUID,
|
||||||
val name: String?,
|
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(
|
data class RemoteTrailer(
|
||||||
override val name: String,
|
override val name: String,
|
||||||
val url: String,
|
val url: String,
|
||||||
|
val subtitle: String?,
|
||||||
) : Trailer
|
) : Trailer
|
||||||
|
|
|
||||||
|
|
@ -414,6 +414,29 @@ sealed interface AppPreference<Pref, T> {
|
||||||
summaryOff = R.string.disabled,
|
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 =
|
val RememberSelectedTab =
|
||||||
AppSwitchPreference<AppPreferences>(
|
AppSwitchPreference<AppPreferences>(
|
||||||
title = R.string.remember_selected_tab,
|
title = R.string.remember_selected_tab,
|
||||||
|
|
@ -696,7 +719,25 @@ sealed interface AppPreference<Pref, T> {
|
||||||
defaultValue = false,
|
defaultValue = false,
|
||||||
getter = { it.playbackPreferences.refreshRateSwitching },
|
getter = { it.playbackPreferences.refreshRateSwitching },
|
||||||
setter = { prefs, value ->
|
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,
|
summaryOn = R.string.automatic,
|
||||||
summaryOff = R.string.disabled,
|
summaryOff = R.string.disabled,
|
||||||
|
|
@ -842,6 +883,13 @@ sealed interface AppPreference<Pref, T> {
|
||||||
summaryOn = R.string.enabled,
|
summaryOn = R.string.enabled,
|
||||||
summaryOff = R.string.disabled,
|
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,
|
title = R.string.more,
|
||||||
preferences =
|
preferences =
|
||||||
listOf(
|
listOf(
|
||||||
|
AppPreference.SeerrIntegration,
|
||||||
AppPreference.AdvancedSettings,
|
AppPreference.AdvancedSettings,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -934,6 +983,7 @@ val advancedPreferences =
|
||||||
AppPreference.GlobalContentScale,
|
AppPreference.GlobalContentScale,
|
||||||
AppPreference.MaxBitrate,
|
AppPreference.MaxBitrate,
|
||||||
AppPreference.RefreshRateSwitching,
|
AppPreference.RefreshRateSwitching,
|
||||||
|
AppPreference.ResolutionSwitching,
|
||||||
AppPreference.PlaybackDebugInfo,
|
AppPreference.PlaybackDebugInfo,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -965,6 +1015,8 @@ val advancedPreferences =
|
||||||
AppPreference.Ac3Supported,
|
AppPreference.Ac3Supported,
|
||||||
AppPreference.DirectPlayAss,
|
AppPreference.DirectPlayAss,
|
||||||
AppPreference.DirectPlayPgs,
|
AppPreference.DirectPlayPgs,
|
||||||
|
AppPreference.DirectPlayDoviProfile7,
|
||||||
|
AppPreference.DecodeAv1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ConditionalPreferences(
|
ConditionalPreferences(
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ class AppPreferencesSerializer
|
||||||
playerBackend = AppPreference.PlayerBackendPref.defaultValue
|
playerBackend = AppPreference.PlayerBackendPref.defaultValue
|
||||||
refreshRateSwitching =
|
refreshRateSwitching =
|
||||||
AppPreference.RefreshRateSwitching.defaultValue
|
AppPreference.RefreshRateSwitching.defaultValue
|
||||||
|
resolutionSwitching = AppPreference.ResolutionSwitching.defaultValue
|
||||||
|
|
||||||
overrides =
|
overrides =
|
||||||
PlaybackOverrides
|
PlaybackOverrides
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import coil3.request.SuccessResult
|
||||||
import coil3.request.allowHardware
|
import coil3.request.allowHardware
|
||||||
import coil3.request.bitmapConfig
|
import coil3.request.bitmapConfig
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
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.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.BackdropStyle
|
import com.github.damontecres.wholphin.preferences.BackdropStyle
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
|
@ -27,7 +28,6 @@ import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
|
@ -47,15 +47,24 @@ class BackdropService
|
||||||
|
|
||||||
suspend fun submit(item: BaseItem) =
|
suspend fun submit(item: BaseItem) =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
|
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) {
|
if (backdropFlow.firstOrNull()?.imageUrl != imageUrl) {
|
||||||
_backdropFlow.update {
|
_backdropFlow.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
itemId = item.id,
|
itemId = itemId,
|
||||||
imageUrl = null,
|
imageUrl = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
extractColors(item)
|
extractColors(itemId, imageUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,9 +74,11 @@ class BackdropService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun extractColors(item: BaseItem) {
|
private suspend fun extractColors(
|
||||||
|
itemId: String,
|
||||||
|
imageUrl: String?,
|
||||||
|
) {
|
||||||
delay(500)
|
delay(500)
|
||||||
val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
|
|
||||||
val backdropStyle =
|
val backdropStyle =
|
||||||
preferences.data
|
preferences.data
|
||||||
.firstOrNull()
|
.firstOrNull()
|
||||||
|
|
@ -83,9 +94,9 @@ class BackdropService
|
||||||
ExtractedColors.DEFAULT
|
ExtractedColors.DEFAULT
|
||||||
}
|
}
|
||||||
_backdropFlow.update {
|
_backdropFlow.update {
|
||||||
if (it.itemId == item.id) {
|
if (it.itemId == itemId) {
|
||||||
BackdropResult(
|
BackdropResult(
|
||||||
itemId = item.id,
|
itemId = itemId,
|
||||||
imageUrl = imageUrl,
|
imageUrl = imageUrl,
|
||||||
primaryColor = primaryColor,
|
primaryColor = primaryColor,
|
||||||
secondaryColor = secondaryColor,
|
secondaryColor = secondaryColor,
|
||||||
|
|
@ -208,7 +219,7 @@ class BackdropService
|
||||||
}
|
}
|
||||||
|
|
||||||
data class BackdropResult(
|
data class BackdropResult(
|
||||||
val itemId: UUID?,
|
val itemId: String?,
|
||||||
val imageUrl: String?,
|
val imageUrl: String?,
|
||||||
val primaryColor: Color = Color.Unspecified,
|
val primaryColor: Color = Color.Unspecified,
|
||||||
val secondaryColor: Color = Color.Unspecified,
|
val secondaryColor: Color = Color.Unspecified,
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ class DeviceProfileService
|
||||||
downMixAudio = prefs.overrides.downmixStereo,
|
downMixAudio = prefs.overrides.downmixStereo,
|
||||||
assDirectPlay = prefs.overrides.directPlayAss,
|
assDirectPlay = prefs.overrides.directPlayAss,
|
||||||
pgsDirectPlay = prefs.overrides.directPlayPgs,
|
pgsDirectPlay = prefs.overrides.directPlayPgs,
|
||||||
|
dolbyVisionELDirectPlay = prefs.overrides.directPlayDolbyVisionEL,
|
||||||
|
decodeAv1 = prefs.overrides.decodeAv1,
|
||||||
jellyfinTenEleven =
|
jellyfinTenEleven =
|
||||||
serverVersion != null && serverVersion >= ServerVersion(10, 11, 0),
|
serverVersion != null && serverVersion >= ServerVersion(10, 11, 0),
|
||||||
)
|
)
|
||||||
|
|
@ -55,6 +57,8 @@ class DeviceProfileService
|
||||||
downMixAudio = newConfig.downMixAudio,
|
downMixAudio = newConfig.downMixAudio,
|
||||||
assDirectPlay = newConfig.assDirectPlay,
|
assDirectPlay = newConfig.assDirectPlay,
|
||||||
pgsDirectPlay = newConfig.pgsDirectPlay,
|
pgsDirectPlay = newConfig.pgsDirectPlay,
|
||||||
|
dolbyVisionELDirectPlay = newConfig.dolbyVisionELDirectPlay,
|
||||||
|
decodeAv1 = prefs.overrides.decodeAv1,
|
||||||
jellyfinTenEleven = newConfig.jellyfinTenEleven,
|
jellyfinTenEleven = newConfig.jellyfinTenEleven,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -72,5 +76,7 @@ data class DeviceProfileConfiguration(
|
||||||
val downMixAudio: Boolean,
|
val downMixAudio: Boolean,
|
||||||
val assDirectPlay: Boolean,
|
val assDirectPlay: Boolean,
|
||||||
val pgsDirectPlay: Boolean,
|
val pgsDirectPlay: Boolean,
|
||||||
|
val dolbyVisionELDirectPlay: Boolean,
|
||||||
|
val decodeAv1: Boolean,
|
||||||
val jellyfinTenEleven: Boolean,
|
val jellyfinTenEleven: Boolean,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,11 @@ class ImageUrlService
|
||||||
itemType: BaseItemKind,
|
itemType: BaseItemKind,
|
||||||
seriesId: UUID?,
|
seriesId: UUID?,
|
||||||
useSeriesForPrimary: Boolean,
|
useSeriesForPrimary: Boolean,
|
||||||
|
imageTags: Map<ImageType, String?>,
|
||||||
imageType: ImageType,
|
imageType: ImageType,
|
||||||
fillWidth: Int? = null,
|
fillWidth: Int? = null,
|
||||||
fillHeight: Int? = null,
|
fillHeight: Int? = null,
|
||||||
): String =
|
): String? =
|
||||||
when (imageType) {
|
when (imageType) {
|
||||||
ImageType.BACKDROP,
|
ImageType.BACKDROP,
|
||||||
ImageType.LOGO,
|
ImageType.LOGO,
|
||||||
|
|
@ -66,6 +67,13 @@ class ImageUrlService
|
||||||
fillWidth = fillWidth,
|
fillWidth = fillWidth,
|
||||||
fillHeight = fillHeight,
|
fillHeight = fillHeight,
|
||||||
)
|
)
|
||||||
|
} else if (seriesId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) {
|
||||||
|
getItemImageUrl(
|
||||||
|
itemId = seriesId,
|
||||||
|
imageType = imageType,
|
||||||
|
fillWidth = fillWidth,
|
||||||
|
fillHeight = fillHeight,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
getItemImageUrl(
|
getItemImageUrl(
|
||||||
itemId = itemId,
|
itemId = itemId,
|
||||||
|
|
@ -98,6 +106,7 @@ class ImageUrlService
|
||||||
itemType = item.type,
|
itemType = item.type,
|
||||||
seriesId = item.data.seriesId,
|
seriesId = item.data.seriesId,
|
||||||
useSeriesForPrimary = item.useSeriesForPrimary,
|
useSeriesForPrimary = item.useSeriesForPrimary,
|
||||||
|
imageTags = item.data.imageTags.orEmpty(),
|
||||||
imageType = imageType,
|
imageType = imageType,
|
||||||
fillWidth = fillWidth,
|
fillWidth = fillWidth,
|
||||||
fillHeight = fillHeight,
|
fillHeight = fillHeight,
|
||||||
|
|
@ -124,8 +133,9 @@ class ImageUrlService
|
||||||
backgroundColor: String? = null,
|
backgroundColor: String? = null,
|
||||||
foregroundLayer: String? = null,
|
foregroundLayer: String? = null,
|
||||||
imageIndex: Int? = null,
|
imageIndex: Int? = null,
|
||||||
): String =
|
): String? {
|
||||||
api.imageApi.getItemImageUrl(
|
if (api.baseUrl.isNullOrBlank()) return null
|
||||||
|
return api.imageApi.getItemImageUrl(
|
||||||
itemId = itemId,
|
itemId = itemId,
|
||||||
imageType = imageType,
|
imageType = imageType,
|
||||||
maxWidth = maxWidth,
|
maxWidth = maxWidth,
|
||||||
|
|
@ -144,6 +154,7 @@ class ImageUrlService
|
||||||
foregroundLayer = foregroundLayer,
|
foregroundLayer = foregroundLayer,
|
||||||
imageIndex = imageIndex,
|
imageIndex = imageIndex,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun getUserImageUrl(userId: UUID) = api.imageApi.getUserImageUrl(userId)
|
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()
|
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() {
|
private fun log() {
|
||||||
val dest = backStack.lastOrNull().toString()
|
val dest = backStack.lastOrNull().toString()
|
||||||
Timber.Forest.i("Current Destination: %s", dest)
|
Timber.i("Current Destination: %s", dest)
|
||||||
ACRA.errorReporter.putCustomData("destination", dest)
|
ACRA.errorReporter.putCustomData("destination", dest)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,10 @@ class PlaybackLifecycleObserver
|
||||||
private var wasPlaying: Boolean? = null
|
private var wasPlaying: Boolean? = null
|
||||||
|
|
||||||
override fun onStart(owner: LifecycleOwner) {
|
override fun onStart(owner: LifecycleOwner) {
|
||||||
|
val lastDest = navigationManager.backStack.lastOrNull()
|
||||||
|
if (lastDest is Destination.Playback || lastDest is Destination.PlaybackList) {
|
||||||
|
navigationManager.goBack()
|
||||||
|
}
|
||||||
wasPlaying = null
|
wasPlaying = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -40,9 +44,6 @@ class PlaybackLifecycleObserver
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStop(owner: LifecycleOwner) {
|
override fun onStop(owner: LifecycleOwner) {
|
||||||
if (navigationManager.backStack.lastOrNull() is Destination.Playback) {
|
|
||||||
navigationManager.goBack()
|
|
||||||
}
|
|
||||||
themeSongPlayer.stop()
|
themeSongPlayer.stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,22 @@
|
||||||
package com.github.damontecres.wholphin.services
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.datasource.DefaultDataSource
|
import androidx.media3.datasource.DefaultDataSource
|
||||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.exoplayer.Renderer
|
||||||
import androidx.media3.exoplayer.RenderersFactory
|
import androidx.media3.exoplayer.RenderersFactory
|
||||||
|
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
|
||||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
|
import androidx.media3.exoplayer.video.MediaCodecVideoRenderer
|
||||||
|
import androidx.media3.exoplayer.video.VideoRendererEventListener
|
||||||
import androidx.media3.extractor.DefaultExtractorsFactory
|
import androidx.media3.extractor.DefaultExtractorsFactory
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
|
@ -27,6 +34,7 @@ import io.github.peerless2012.ass.media.type.AssRenderType
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import java.lang.reflect.Constructor
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
|
@ -74,6 +82,7 @@ class PlayerFactory
|
||||||
val directPlayAss =
|
val directPlayAss =
|
||||||
prefs?.overrides?.directPlayAss
|
prefs?.overrides?.directPlayAss
|
||||||
?: AppPreference.DirectPlayAss.defaultValue
|
?: AppPreference.DirectPlayAss.defaultValue
|
||||||
|
val decodeAv1 = prefs?.overrides?.decodeAv1 == true
|
||||||
Timber.v("extensions=$extensions, directPlayAss=$directPlayAss")
|
Timber.v("extensions=$extensions, directPlayAss=$directPlayAss")
|
||||||
val rendererMode =
|
val rendererMode =
|
||||||
when (extensions) {
|
when (extensions) {
|
||||||
|
|
@ -85,7 +94,7 @@ class PlayerFactory
|
||||||
val dataSourceFactory = DefaultDataSource.Factory(context)
|
val dataSourceFactory = DefaultDataSource.Factory(context)
|
||||||
val extractorsFactory = DefaultExtractorsFactory()
|
val extractorsFactory = DefaultExtractorsFactory()
|
||||||
var renderersFactory: RenderersFactory =
|
var renderersFactory: RenderersFactory =
|
||||||
DefaultRenderersFactory(context)
|
WholphinRenderersFactory(context, decodeAv1)
|
||||||
.setEnableDecoderFallback(true)
|
.setEnableDecoderFallback(true)
|
||||||
.setExtensionRendererMode(rendererMode)
|
.setExtensionRendererMode(rendererMode)
|
||||||
val mediaSourceFactory =
|
val mediaSourceFactory =
|
||||||
|
|
@ -135,3 +144,73 @@ data class PlayerCreation(
|
||||||
val player: Player,
|
val player: Player,
|
||||||
val assHandler: AssHandler? = null,
|
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 display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
|
||||||
private val originalMode = display.mode
|
private val originalMode = display.mode
|
||||||
|
|
||||||
val displayModes get() = display.supportedModes
|
val supportedDisplayModes get() = display.supportedModes.orEmpty()
|
||||||
|
|
||||||
private val _refreshRateMode = EqualityMutableLiveData<Display.Mode>(originalMode)
|
private val displayModes: List<DisplayMode> by lazy {
|
||||||
val refreshRateMode: LiveData<Display.Mode> = _refreshRateMode
|
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
|
* 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" }
|
require(stream.type == MediaStreamType.VIDEO) { "Stream is not video" }
|
||||||
val width = stream.width
|
val width = stream.width
|
||||||
val height = stream.height
|
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) {
|
if (width == null || height == null || frameRate == null) {
|
||||||
Timber.w("Video stream missing required info: width=%s, height=%s, frameRate=%s", width, height, frameRate)
|
Timber.w("Video stream missing required info: width=%s, height=%s, frameRate=%s", width, height, frameRate)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Timber.d("Getting refresh rate for: width=%s, height=%s, frameRate=%s", width, height, frameRate)
|
Timber.d("Getting refresh rate for: width=%s, height=%s, frameRate=%s", width, height, frameRate)
|
||||||
val targetMode =
|
val targetMode =
|
||||||
display.supportedModes
|
findDisplayMode(
|
||||||
.filterNot { it.physicalHeight < height || it.physicalWidth < width }
|
displayModes = displayModes,
|
||||||
.filter {
|
streamWidth = width,
|
||||||
(it.refreshRate * 100).roundToInt().let { modeRate ->
|
streamHeight = height,
|
||||||
frameRate % modeRate == 0 || // Exact multiple
|
targetFrameRate = frameRate,
|
||||||
modeRate == (frameRate * 2.5).roundToInt() // eg 24 & 60fps
|
refreshRateSwitch = switchRefreshRate,
|
||||||
}
|
resolutionSwitch = switchResolution,
|
||||||
}.maxByOrNull { it.physicalWidth * it.physicalHeight }
|
)
|
||||||
Timber.i("Found display mode: %s, current=${display.mode}", targetMode)
|
Timber.i("Found display mode: %s, current=%s", targetMode, currentDisplayMode)
|
||||||
if (targetMode != null && targetMode != display.mode) {
|
if (targetMode != null && targetMode.modeId != currentDisplayMode.modeId) {
|
||||||
val listener = Listener(display.displayId)
|
val listener = Listener(display.displayId)
|
||||||
displayManager.registerDisplayListener(
|
displayManager.registerDisplayListener(
|
||||||
listener,
|
listener,
|
||||||
Handler(Looper.myLooper() ?: Looper.getMainLooper()),
|
Handler(Looper.myLooper() ?: Looper.getMainLooper()),
|
||||||
)
|
)
|
||||||
_refreshRateMode.setValueOnMain(targetMode)
|
_refreshRateMode.setValueOnMain(targetMode.modeId)
|
||||||
try {
|
try {
|
||||||
if (!listener.latch.await(5, TimeUnit.SECONDS)) {
|
if (!listener.latch.await(5, TimeUnit.SECONDS)) {
|
||||||
Timber.w("Timed out waiting for display change")
|
Timber.w("Timed out waiting for display change")
|
||||||
|
|
@ -75,13 +95,13 @@ class RefreshRateService
|
||||||
} catch (ex: InterruptedException) {
|
} catch (ex: InterruptedException) {
|
||||||
Timber.w(ex, "Exception waiting for refresh rate switch")
|
Timber.w(ex, "Exception waiting for refresh rate switch")
|
||||||
}
|
}
|
||||||
val targetRate = (targetMode.refreshRate * 100).roundToInt()
|
val targetRate = (targetMode.refreshRate * 1000).roundToInt()
|
||||||
val isSeamless =
|
val isSeamless =
|
||||||
targetRate == (display.mode.refreshRate * 100).roundToInt() ||
|
targetRate == (currentDisplayMode.refreshRate * 1000).roundToInt() ||
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
display.mode.alternativeRefreshRates
|
currentDisplayMode.alternativeRefreshRates
|
||||||
.map { (it * 100).roundToInt() }
|
.map { (it * 1000).roundToInt() }
|
||||||
.any { targetRate % it == 0 }
|
.any { it % targetRate == 0 }
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
@ -98,7 +118,7 @@ class RefreshRateService
|
||||||
* Reset the display mode to the original
|
* Reset the display mode to the original
|
||||||
*/
|
*/
|
||||||
fun resetRefreshRate() {
|
fun resetRefreshRate() {
|
||||||
_refreshRateMode.value = originalMode
|
_refreshRateMode.value = originalMode.modeId
|
||||||
}
|
}
|
||||||
|
|
||||||
private class Listener(
|
private class Listener(
|
||||||
|
|
@ -119,4 +139,69 @@ class RefreshRateService
|
||||||
override fun onDisplayRemoved(displayId: Int) {
|
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 ->
|
return source.mediaStreams?.letNotEmpty { streams ->
|
||||||
val candidates = streams.filter { it.type == MediaStreamType.SUBTITLE }
|
val candidates = streams.filter { it.type == MediaStreamType.SUBTITLE }
|
||||||
chooseSubtitleStream(
|
chooseSubtitleStream(
|
||||||
audioStream,
|
audioStream?.language,
|
||||||
candidates,
|
candidates,
|
||||||
itemPlayback,
|
itemPlayback,
|
||||||
plc,
|
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(
|
fun chooseSubtitleStream(
|
||||||
audioStream: MediaStream?,
|
audioStreamLang: String?,
|
||||||
candidates: List<MediaStream>,
|
candidates: List<MediaStream>,
|
||||||
itemPlayback: ItemPlayback?,
|
itemPlayback: ItemPlayback?,
|
||||||
playbackLanguageChoice: PlaybackLanguageChoice?,
|
playbackLanguageChoice: PlaybackLanguageChoice?,
|
||||||
|
|
@ -171,6 +205,14 @@ class StreamChoiceService
|
||||||
): MediaStream? {
|
): MediaStream? {
|
||||||
if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) {
|
if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) {
|
||||||
return null
|
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) {
|
} else if (itemPlayback?.subtitleIndexEnabled == true) {
|
||||||
return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex }
|
return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex }
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -198,48 +240,61 @@ class StreamChoiceService
|
||||||
userConfig?.subtitleMode ?: SubtitlePlaybackMode.DEFAULT
|
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) {
|
return when (subtitleMode) {
|
||||||
SubtitlePlaybackMode.ALWAYS -> {
|
SubtitlePlaybackMode.ALWAYS -> {
|
||||||
if (subtitleLanguage != null) {
|
if (subtitleLanguage.isNotNullOrBlank()) {
|
||||||
candidates.firstOrNull { it.language == subtitleLanguage }
|
candidates.firstOrNull {
|
||||||
|
it.language.equals(subtitleLanguage, true) ||
|
||||||
|
it.language.isUnknown
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
candidates.firstOrNull()
|
candidates.firstOrNull()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SubtitlePlaybackMode.ONLY_FORCED -> {
|
SubtitlePlaybackMode.ONLY_FORCED -> {
|
||||||
if (subtitleLanguage != null) {
|
if (subtitleLanguage.isNotNullOrBlank()) {
|
||||||
candidates.firstOrNull { it.language == subtitleLanguage && it.isForced }
|
candidates.firstOrNull { it.language == subtitleLanguage && it.isForced }
|
||||||
|
?: candidates.firstOrNull { it.language.isUnknown && it.isForced }
|
||||||
} else {
|
} else {
|
||||||
candidates.firstOrNull { it.isForced }
|
candidates.firstOrNull { it.isForced }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SubtitlePlaybackMode.SMART -> {
|
SubtitlePlaybackMode.SMART -> {
|
||||||
|
if (subtitleLanguage.isNotNullOrBlank()) {
|
||||||
val audioLanguage = userConfig?.audioLanguagePreference
|
val audioLanguage = userConfig?.audioLanguagePreference
|
||||||
val audioStreamLang = audioStream?.language
|
if (audioLanguage.isNullOrBlank() || audioLanguage != audioStreamLang) {
|
||||||
if (audioLanguage.isNotNullOrBlank() && audioStreamLang.isNotNullOrBlank() && audioLanguage != audioStreamLang) {
|
|
||||||
candidates.firstOrNull { it.language == subtitleLanguage }
|
candidates.firstOrNull { it.language == subtitleLanguage }
|
||||||
|
?: candidates.firstOrNull { it.language.isUnknown }
|
||||||
} else {
|
} else {
|
||||||
null
|
candidates.firstOrNull { it.isForced && it.language == subtitleLanguage }
|
||||||
|
?: candidates.firstOrNull { it.isForced && it.language.isUnknown }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
candidates.firstOrNull { it.isDefault }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SubtitlePlaybackMode.DEFAULT -> {
|
SubtitlePlaybackMode.DEFAULT -> {
|
||||||
subtitleLanguage?.let { lang ->
|
if (subtitleLanguage.isNotNullOrBlank()) {
|
||||||
// Find best track that is in the preferred language
|
candidates.firstOrNull { it.language == subtitleLanguage && (it.isDefault || it.isForced) }
|
||||||
(
|
?: candidates.firstOrNull { it.isDefault || it.isForced }
|
||||||
candidates.firstOrNull { it.isDefault && it.isForced && it.language == lang }
|
} else {
|
||||||
?: candidates.firstOrNull { it.isDefault && it.language == lang }
|
candidates.firstOrNull { it.isDefault || it.isForced }
|
||||||
?: candidates.firstOrNull { it.isForced && it.language == lang }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
?: (
|
|
||||||
// If none in preferred language, just find the best track
|
|
||||||
candidates.firstOrNull { it.isDefault && it.isForced }
|
|
||||||
?: candidates.firstOrNull { it.isDefault }
|
|
||||||
?: candidates.firstOrNull { it.isForced }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SubtitlePlaybackMode.NONE -> {
|
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() {
|
fun stop() {
|
||||||
|
if (player.isPlaying) {
|
||||||
Timber.v("Stopping theme song")
|
Timber.v("Stopping theme song")
|
||||||
player.stop()
|
player.stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,7 @@ class TrailerService
|
||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
private val api: ApiClient,
|
private val api: ApiClient,
|
||||||
) {
|
) {
|
||||||
suspend fun getTrailers(item: BaseItem): List<Trailer> {
|
fun getRemoteTrailers(item: BaseItem): List<Trailer> =
|
||||||
val remoteTrailers =
|
|
||||||
item.data.remoteTrailers
|
item.data.remoteTrailers
|
||||||
?.mapNotNull { t ->
|
?.mapNotNull { t ->
|
||||||
t.url?.let { url ->
|
t.url?.let { url ->
|
||||||
|
|
@ -33,20 +32,43 @@ class TrailerService
|
||||||
// ?.replace(item.name ?: "", "")
|
// ?.replace(item.name ?: "", "")
|
||||||
// ?.removePrefix(" - ")
|
// ?.removePrefix(" - ")
|
||||||
?: context.getString(R.string.trailer)
|
?: context.getString(R.string.trailer)
|
||||||
RemoteTrailer(name, url)
|
val subtitle =
|
||||||
|
when (url.toUri().host) {
|
||||||
|
"youtube.com", "www.youtube.com" -> "YouTube"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
RemoteTrailer(name, url, subtitle)
|
||||||
}
|
}
|
||||||
}.orEmpty()
|
}.orEmpty()
|
||||||
.sortedBy { it.name }
|
.sortedWith(
|
||||||
val localTrailerCount = item.data.localTrailerCount ?: 0
|
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 =
|
val localTrailers =
|
||||||
if (localTrailerCount > 0) {
|
if (localTrailerCount > 0) {
|
||||||
api.userLibraryApi.getLocalTrailers(item.id).content.map {
|
api.userLibraryApi.getLocalTrailers(item.id).content.map {
|
||||||
LocalTrailer(BaseItem.Companion.from(it, api))
|
LocalTrailer(BaseItem.from(it, api))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
listOf()
|
listOf()
|
||||||
}
|
}
|
||||||
return localTrailers + remoteTrailers
|
return localTrailers
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
@ -66,7 +88,6 @@ class TrailerService
|
||||||
navigateTo.invoke(
|
navigateTo.invoke(
|
||||||
Destination.Playback(
|
Destination.Playback(
|
||||||
itemId = trailer.baseItem.id,
|
itemId = trailer.baseItem.id,
|
||||||
item = trailer.baseItem,
|
|
||||||
positionMs = 0L,
|
positionMs = 0L,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,6 @@ import kotlinx.serialization.json.jsonPrimitive
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okhttp3.internal.headersContentLength
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
@ -211,7 +210,7 @@ class UpdateChecker
|
||||||
if (it.isSuccessful && it.body != null) {
|
if (it.isSuccessful && it.body != null) {
|
||||||
Timber.v("Request successful for ${release.downloadUrl}")
|
Timber.v("Request successful for ${release.downloadUrl}")
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
callback.contentLength(it.headersContentLength())
|
callback.contentLength(it.body.contentLength())
|
||||||
}
|
}
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
val contentValues =
|
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.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
|
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.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.RememberTabManager
|
import com.github.damontecres.wholphin.util.RememberTabManager
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
|
|
@ -174,4 +175,10 @@ object AppModule {
|
||||||
@Singleton
|
@Singleton
|
||||||
@IoCoroutineScope
|
@IoCoroutineScope
|
||||||
fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
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.LibraryDisplayInfoDao
|
||||||
import com.github.damontecres.wholphin.data.Migrations
|
import com.github.damontecres.wholphin.data.Migrations
|
||||||
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
|
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
|
||||||
|
import com.github.damontecres.wholphin.data.SeerrServerDao
|
||||||
import com.github.damontecres.wholphin.data.ServerPreferencesDao
|
import com.github.damontecres.wholphin.data.ServerPreferencesDao
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
|
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
|
||||||
|
|
@ -61,6 +62,10 @@ object DatabaseModule {
|
||||||
@Singleton
|
@Singleton
|
||||||
fun playbackLanguageChoiceDao(db: AppDatabase): PlaybackLanguageChoiceDao = db.playbackLanguageChoiceDao()
|
fun playbackLanguageChoiceDao(db: AppDatabase): PlaybackLanguageChoiceDao = db.playbackLanguageChoiceDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun seerrServerDao(db: AppDatabase): SeerrServerDao = db.seerrServerDao()
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun userPreferencesDataStore(
|
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
|
* 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 {
|
try {
|
||||||
requestFocus()
|
requestFocus()
|
||||||
|
tag?.let { Timber.v("Request focus tag=%s", tag) }
|
||||||
true
|
true
|
||||||
} catch (ex: IllegalStateException) {
|
} catch (ex: IllegalStateException) {
|
||||||
Timber.w(ex, "Failed to request focus")
|
Timber.w(ex, "Failed to request focus, tag=%s", tag)
|
||||||
false
|
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) =
|
fun Modifier.enableMarquee(focused: Boolean) =
|
||||||
if (focused) {
|
if (focused) {
|
||||||
basicMarquee(
|
basicMarquee(
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,17 @@
|
||||||
package com.github.damontecres.wholphin.ui
|
package com.github.damontecres.wholphin.ui
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
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 com.github.damontecres.wholphin.R
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.api.MediaSegmentType
|
import org.jellyfin.sdk.model.api.MediaSegmentType
|
||||||
|
import timber.log.Timber
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.DateTimeParseException
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
import java.util.Locale
|
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 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#`
|
* 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.OUTRO -> R.string.skip_segment_outro
|
||||||
MediaSegmentType.INTRO -> R.string.skip_segment_intro
|
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,8 +21,7 @@ val LocalImageUrlService =
|
||||||
/**
|
/**
|
||||||
* Colors not associated with the theme
|
* Colors not associated with the theme
|
||||||
*/
|
*/
|
||||||
sealed class AppColors private constructor() {
|
object AppColors {
|
||||||
companion object {
|
|
||||||
val TransparentBlack25 = Color(0x40000000)
|
val TransparentBlack25 = Color(0x40000000)
|
||||||
val TransparentBlack50 = Color(0x80000000)
|
val TransparentBlack50 = Color(0x80000000)
|
||||||
val TransparentBlack75 = Color(0xBF000000)
|
val TransparentBlack75 = Color(0xBF000000)
|
||||||
|
|
@ -33,6 +32,12 @@ sealed class AppColors private constructor() {
|
||||||
val DarkPurple = Color(0xFF261370)
|
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.SORT_NAME,
|
||||||
ItemFields.CHAPTERS,
|
ItemFields.CHAPTERS,
|
||||||
ItemFields.MEDIA_SOURCES,
|
ItemFields.MEDIA_SOURCES,
|
||||||
|
ItemFields.MEDIA_SOURCE_COUNT,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -63,10 +69,12 @@ val SlimItemFields =
|
||||||
ItemFields.CHILD_COUNT,
|
ItemFields.CHILD_COUNT,
|
||||||
ItemFields.OVERVIEW,
|
ItemFields.OVERVIEW,
|
||||||
ItemFields.SORT_NAME,
|
ItemFields.SORT_NAME,
|
||||||
|
ItemFields.MEDIA_SOURCE_COUNT,
|
||||||
)
|
)
|
||||||
|
|
||||||
object Cards {
|
object Cards {
|
||||||
val height2x3 = 172.dp
|
val height2x3 = 172.dp
|
||||||
|
val heightEpisode = height2x3 * .75f
|
||||||
val playedPercentHeight = 6.dp
|
val playedPercentHeight = 6.dp
|
||||||
val serverUserCircle = height2x3 * .75f
|
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,
|
watched = dto?.userData?.played ?: false,
|
||||||
unwatchedCount = dto?.userData?.unplayedItemCount ?: -1,
|
unwatchedCount = dto?.userData?.unplayedItemCount ?: -1,
|
||||||
watchedPercent = dto?.userData?.playedPercentage,
|
watchedPercent = dto?.userData?.playedPercentage,
|
||||||
|
numberOfVersions = dto?.mediaSourceCount ?: 0,
|
||||||
useFallbackText = false,
|
useFallbackText = false,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ fun ExtrasRow(
|
||||||
isPlayed = false,
|
isPlayed = false,
|
||||||
unplayedItemCount = -1,
|
unplayedItemCount = -1,
|
||||||
playedPercentage = -1.0,
|
playedPercentage = -1.0,
|
||||||
|
numberOfVersions = -1,
|
||||||
aspectRatio = AspectRatios.FOUR_THREE, // TODO
|
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.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.material3.Card
|
import androidx.tv.material3.Card
|
||||||
import androidx.tv.material3.CardDefaults
|
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.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.setup.rememberIdColor
|
import com.github.damontecres.wholphin.ui.setup.rememberIdColor
|
||||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
import timber.log.Timber
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -77,7 +75,7 @@ fun GenreCard(
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.alpha(.6f)
|
.alpha(.75f)
|
||||||
.aspectRatio(AspectRatios.WIDE)
|
.aspectRatio(AspectRatios.WIDE)
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,7 @@ fun GridCard(
|
||||||
watched = dto?.userData?.played ?: false,
|
watched = dto?.userData?.played ?: false,
|
||||||
unwatchedCount = dto?.userData?.unplayedItemCount ?: -1,
|
unwatchedCount = dto?.userData?.unplayedItemCount ?: -1,
|
||||||
watchedPercent = dto?.userData?.playedPercentage,
|
watchedPercent = dto?.userData?.playedPercentage,
|
||||||
|
numberOfVersions = dto?.mediaSourceCount ?: 0,
|
||||||
useFallbackText = false,
|
useFallbackText = false,
|
||||||
contentScale = imageContentScale,
|
contentScale = imageContentScale,
|
||||||
modifier =
|
modifier =
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ fun ItemCardImage(
|
||||||
watched: Boolean,
|
watched: Boolean,
|
||||||
unwatchedCount: Int,
|
unwatchedCount: Int,
|
||||||
watchedPercent: Double?,
|
watchedPercent: Double?,
|
||||||
|
numberOfVersions: Int,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
imageType: ImageType = ImageType.PRIMARY,
|
imageType: ImageType = ImageType.PRIMARY,
|
||||||
useFallbackText: Boolean = true,
|
useFallbackText: Boolean = true,
|
||||||
|
|
@ -86,6 +87,7 @@ fun ItemCardImage(
|
||||||
watched = watched,
|
watched = watched,
|
||||||
unwatchedCount = unwatchedCount,
|
unwatchedCount = unwatchedCount,
|
||||||
watchedPercent = watchedPercent,
|
watchedPercent = watchedPercent,
|
||||||
|
numberOfVersions = numberOfVersions,
|
||||||
modifier =
|
modifier =
|
||||||
modifier.onLayoutRectChanged(
|
modifier.onLayoutRectChanged(
|
||||||
throttleMillis = 100,
|
throttleMillis = 100,
|
||||||
|
|
@ -107,9 +109,17 @@ fun ItemCardImage(
|
||||||
watched: Boolean,
|
watched: Boolean,
|
||||||
unwatchedCount: Int,
|
unwatchedCount: Int,
|
||||||
watchedPercent: Double?,
|
watchedPercent: Double?,
|
||||||
|
numberOfVersions: Int,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
useFallbackText: Boolean = true,
|
useFallbackText: Boolean = true,
|
||||||
contentScale: ContentScale = ContentScale.Fit,
|
contentScale: ContentScale = ContentScale.Fit,
|
||||||
|
fallback: @Composable BoxScope.() -> Unit = {
|
||||||
|
ItemCardImageFallback(
|
||||||
|
name = name,
|
||||||
|
useFallbackText = useFallbackText,
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
var imageError by remember(imageUrl) { mutableStateOf(false) }
|
var imageError by remember(imageUrl) { mutableStateOf(false) }
|
||||||
Box(
|
Box(
|
||||||
|
|
@ -131,11 +141,7 @@ fun ItemCardImage(
|
||||||
.align(Alignment.TopCenter),
|
.align(Alignment.TopCenter),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
ItemCardImageFallback(
|
fallback.invoke(this)
|
||||||
name = name,
|
|
||||||
useFallbackText = useFallbackText,
|
|
||||||
modifier = Modifier,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (showOverlay) {
|
if (showOverlay) {
|
||||||
ItemCardImageOverlay(
|
ItemCardImageOverlay(
|
||||||
|
|
@ -143,6 +149,7 @@ fun ItemCardImage(
|
||||||
watched = watched,
|
watched = watched,
|
||||||
unwatchedCount = unwatchedCount,
|
unwatchedCount = unwatchedCount,
|
||||||
watchedPercent = watchedPercent,
|
watchedPercent = watchedPercent,
|
||||||
|
numberOfVersions = numberOfVersions,
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -198,21 +205,46 @@ fun ItemCardImageOverlay(
|
||||||
watched: Boolean,
|
watched: Boolean,
|
||||||
unwatchedCount: Int,
|
unwatchedCount: Int,
|
||||||
watchedPercent: Double?,
|
watchedPercent: Double?,
|
||||||
|
numberOfVersions: Int,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Box(modifier = modifier.fillMaxSize()) {
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
if (favorite) {
|
Row(
|
||||||
Text(
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.TopStart)
|
.align(Alignment.TopStart)
|
||||||
.padding(8.dp),
|
.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),
|
color = colorResource(android.R.color.holo_red_light),
|
||||||
text = stringResource(R.string.fa_heart),
|
text = stringResource(R.string.fa_heart),
|
||||||
fontSize = 20.sp,
|
fontSize = 20.sp,
|
||||||
fontFamily = FontAwesome,
|
fontFamily = FontAwesome,
|
||||||
|
modifier = Modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.github.damontecres.wholphin.ui.cards
|
package com.github.damontecres.wholphin.ui.cards
|
||||||
|
|
||||||
|
import androidx.compose.foundation.focusGroup
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
|
@ -9,21 +10,19 @@ import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusProperties
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.focusRestorer
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.ui.rememberInt
|
||||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
import com.github.damontecres.wholphin.util.FocusPair
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun <T> ItemRow(
|
fun <T> ItemRow(
|
||||||
|
|
@ -39,16 +38,20 @@ fun <T> ItemRow(
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
) -> Unit,
|
) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
focusPair: FocusPair? = null,
|
|
||||||
cardOnFocus: ((isFocused: Boolean, index: Int) -> Unit)? = null,
|
|
||||||
horizontalPadding: Dp = 16.dp,
|
horizontalPadding: Dp = 16.dp,
|
||||||
) {
|
) {
|
||||||
val state = rememberLazyListState()
|
val state = rememberLazyListState()
|
||||||
val firstFocus = remember { FocusRequester() }
|
val firstFocus = remember { FocusRequester() }
|
||||||
var focusedIndex by remember { mutableIntStateOf(focusPair?.column ?: 0) }
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
var position by rememberInt()
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = modifier,
|
modifier =
|
||||||
|
modifier.focusProperties {
|
||||||
|
onEnter = {
|
||||||
|
focusRequester.tryRequestFocus()
|
||||||
|
}
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
|
|
@ -62,67 +65,31 @@ fun <T> ItemRow(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.focusRestorer(focusPair?.focusRequester ?: firstFocus),
|
.focusGroup()
|
||||||
|
.focusRestorer(firstFocus)
|
||||||
|
.focusRequester(focusRequester),
|
||||||
) {
|
) {
|
||||||
itemsIndexed(items) { index, item ->
|
itemsIndexed(items) { index, item ->
|
||||||
val cardModifier =
|
val cardModifier =
|
||||||
if (index == 0 && focusPair == null) {
|
if (index == position) {
|
||||||
Modifier.focusRequester(firstFocus)
|
Modifier.focusRequester(firstFocus)
|
||||||
} else {
|
|
||||||
if (focusPair != null && focusPair.column == index) {
|
|
||||||
Modifier.focusRequester(focusPair.focusRequester)
|
|
||||||
} else {
|
} else {
|
||||||
Modifier
|
Modifier
|
||||||
}
|
}
|
||||||
}.onFocusChanged {
|
|
||||||
if (it.isFocused) {
|
|
||||||
focusedIndex = index
|
|
||||||
}
|
|
||||||
cardOnFocus?.invoke(it.isFocused, index)
|
|
||||||
}
|
|
||||||
cardContent.invoke(
|
cardContent.invoke(
|
||||||
index,
|
index,
|
||||||
item,
|
item,
|
||||||
cardModifier,
|
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
|
package com.github.damontecres.wholphin.ui.cards
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
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.MutableInteractionSource
|
||||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
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.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.Border
|
||||||
import androidx.tv.material3.Card
|
import androidx.tv.material3.Card
|
||||||
import androidx.tv.material3.CardDefaults
|
import androidx.tv.material3.CardDefaults
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.model.Person
|
import com.github.damontecres.wholphin.data.model.Person
|
||||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
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.enableMarquee
|
||||||
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
import kotlinx.coroutines.delay
|
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
|
* A Card for a [Person] such as an actor or director
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun PersonCard(
|
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,
|
onClick: () -> Unit,
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|
@ -65,24 +105,62 @@ fun PersonCard(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
|
shape = CardDefaults.shape(CircleShape),
|
||||||
|
border =
|
||||||
|
CardDefaults.border(
|
||||||
|
focusedBorder =
|
||||||
|
Border(
|
||||||
|
border =
|
||||||
|
BorderStroke(
|
||||||
|
width = 3.dp,
|
||||||
|
color = MaterialTheme.colorScheme.border,
|
||||||
|
),
|
||||||
|
shape = CircleShape,
|
||||||
|
),
|
||||||
|
),
|
||||||
colors =
|
colors =
|
||||||
CardDefaults.colors(
|
CardDefaults.colors(
|
||||||
containerColor = Color.Transparent,
|
containerColor = Color.Transparent,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
ItemCardImage(
|
ItemCardImage(
|
||||||
imageUrl = item.imageUrl,
|
imageUrl = imageUrl,
|
||||||
name = item.name,
|
name = name,
|
||||||
showOverlay = true,
|
showOverlay = false,
|
||||||
favorite = item.favorite,
|
favorite = favorite,
|
||||||
watched = false,
|
watched = false,
|
||||||
unwatchedCount = -1,
|
unwatchedCount = -1,
|
||||||
|
numberOfVersions = -1,
|
||||||
watchedPercent = null,
|
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 =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.aspectRatio(AspectRatios.TALL), // TODO,
|
.aspectRatio(AspectRatios.SQUARE)
|
||||||
|
.clip(CircleShape),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
|
|
@ -93,7 +171,7 @@ fun PersonCard(
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = item.name ?: "",
|
text = name ?: "",
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier =
|
modifier =
|
||||||
|
|
@ -102,9 +180,9 @@ fun PersonCard(
|
||||||
.padding(horizontal = 4.dp)
|
.padding(horizontal = 4.dp)
|
||||||
.enableMarquee(focusedAfterDelay),
|
.enableMarquee(focusedAfterDelay),
|
||||||
)
|
)
|
||||||
item.role?.let {
|
role?.let {
|
||||||
Text(
|
Text(
|
||||||
text = item.role,
|
text = role,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier =
|
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.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
|
@ -21,8 +23,10 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.R
|
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.data.model.Person
|
||||||
import com.github.damontecres.wholphin.ui.ifElse
|
import com.github.damontecres.wholphin.ui.ifElse
|
||||||
|
import com.github.damontecres.wholphin.ui.rememberInt
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PersonRow(
|
fun PersonRow(
|
||||||
|
|
@ -31,6 +35,57 @@ fun PersonRow(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@StringRes title: Int = R.string.people,
|
@StringRes title: Int = R.string.people,
|
||||||
onLongClick: ((Int, Person) -> Unit)? = null,
|
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() }
|
val firstFocus = remember { FocusRequester() }
|
||||||
Column(
|
Column(
|
||||||
|
|
@ -52,14 +107,17 @@ fun PersonRow(
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.focusRestorer(firstFocus),
|
.focusRestorer(firstFocus),
|
||||||
) {
|
) {
|
||||||
itemsIndexed(people) { index, item ->
|
itemsIndexed(people) { index, person ->
|
||||||
PersonCard(
|
PersonCard(
|
||||||
item = item,
|
name = person.title,
|
||||||
onClick = { onClick.invoke(item) },
|
role = person.subtitle,
|
||||||
onLongClick = { onLongClick?.invoke(index, item) },
|
imageUrl = person.posterUrl,
|
||||||
|
favorite = false,
|
||||||
|
onClick = { onClick.invoke(person) },
|
||||||
|
onLongClick = { onLongClick?.invoke(index, person) },
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.width(120.dp)
|
.width(personRowCardWidth)
|
||||||
.ifElse(index == 0, Modifier.focusRequester(firstFocus))
|
.ifElse(index == 0, Modifier.focusRequester(firstFocus))
|
||||||
.animateItem(),
|
.animateItem(),
|
||||||
)
|
)
|
||||||
|
|
@ -67,3 +125,5 @@ fun PersonRow(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val personRowCardWidth = 108.dp
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,7 @@ fun SeasonCard(
|
||||||
isPlayed = item?.data?.userData?.played ?: false,
|
isPlayed = item?.data?.userData?.played ?: false,
|
||||||
unplayedItemCount = item?.data?.userData?.unplayedItemCount ?: 0,
|
unplayedItemCount = item?.data?.userData?.unplayedItemCount ?: 0,
|
||||||
playedPercentage = item?.data?.userData?.playedPercentage ?: 0.0,
|
playedPercentage = item?.data?.userData?.playedPercentage ?: 0.0,
|
||||||
|
numberOfVersions = item?.data?.mediaSourceCount ?: 0,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|
@ -112,6 +113,7 @@ fun SeasonCard(
|
||||||
isPlayed: Boolean,
|
isPlayed: Boolean,
|
||||||
unplayedItemCount: Int,
|
unplayedItemCount: Int,
|
||||||
playedPercentage: Double,
|
playedPercentage: Double,
|
||||||
|
numberOfVersions: Int,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|
@ -172,6 +174,7 @@ fun SeasonCard(
|
||||||
watched = isPlayed,
|
watched = isPlayed,
|
||||||
unwatchedCount = unplayedItemCount,
|
unwatchedCount = unplayedItemCount,
|
||||||
watchedPercent = playedPercentage,
|
watchedPercent = playedPercentage,
|
||||||
|
numberOfVersions = numberOfVersions,
|
||||||
useFallbackText = false,
|
useFallbackText = false,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
|
@ -39,6 +40,7 @@ import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
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.FavoriteWatchManager
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||||
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.SlimItemFields
|
||||||
import com.github.damontecres.wholphin.ui.cards.GridCard
|
import com.github.damontecres.wholphin.ui.cards.GridCard
|
||||||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
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.toServerString
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||||
|
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.GetPersonsHandler
|
import com.github.damontecres.wholphin.util.GetPersonsHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
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.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
|
@ -114,13 +118,13 @@ import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.TreeSet
|
import java.util.TreeSet
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel(assistedFactory = CollectionFolderViewModel.Factory::class)
|
||||||
class CollectionFolderViewModel
|
class CollectionFolderViewModel
|
||||||
@Inject
|
@AssistedInject
|
||||||
constructor(
|
constructor(
|
||||||
|
private val savedStateHandle: SavedStateHandle,
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
private val serverRepository: ServerRepository,
|
private val serverRepository: ServerRepository,
|
||||||
|
|
@ -128,34 +132,41 @@ class CollectionFolderViewModel
|
||||||
private val favoriteWatchManager: FavoriteWatchManager,
|
private val favoriteWatchManager: FavoriteWatchManager,
|
||||||
private val backdropService: BackdropService,
|
private val backdropService: BackdropService,
|
||||||
val navigationManager: NavigationManager,
|
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) {
|
) : 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 backgroundLoading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
||||||
val pager = MutableLiveData<List<BaseItem?>>(listOf())
|
|
||||||
val sortAndDirection = MutableLiveData<SortAndDirection>()
|
val sortAndDirection = MutableLiveData<SortAndDirection>()
|
||||||
val filter = MutableLiveData<GetItemsFilter>(GetItemsFilter())
|
val filter = MutableLiveData<GetItemsFilter>(GetItemsFilter())
|
||||||
val viewOptions = MutableLiveData<ViewOptions>()
|
val viewOptions = MutableLiveData<ViewOptions>()
|
||||||
|
|
||||||
private var useSeriesForPrimary: Boolean = true
|
var position: Int
|
||||||
private lateinit var collectionFilter: CollectionFolderFilter
|
get() = savedStateHandle.get<Int>("position") ?: 0
|
||||||
|
set(value) {
|
||||||
|
savedStateHandle["position"] = value
|
||||||
|
}
|
||||||
|
|
||||||
fun init(
|
init {
|
||||||
itemId: String,
|
viewModelScope.launchIO {
|
||||||
initialSortAndDirection: SortAndDirection?,
|
super.itemId = itemId
|
||||||
recursive: Boolean,
|
try {
|
||||||
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 {
|
itemId.toUUIDOrNull()?.let {
|
||||||
fetchItem(it)
|
fetchItem(it)
|
||||||
}
|
}
|
||||||
|
|
@ -183,6 +194,11 @@ class CollectionFolderViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary)
|
loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary)
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Error during init")
|
||||||
|
loading.setValueOnMain(DataLoadingState.Error(ex))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveLibraryDisplayInfo(
|
private fun saveLibraryDisplayInfo(
|
||||||
|
|
@ -192,7 +208,7 @@ class CollectionFolderViewModel
|
||||||
) {
|
) {
|
||||||
if (collectionFilter.useSavedLibraryDisplayInfo) {
|
if (collectionFilter.useSavedLibraryDisplayInfo) {
|
||||||
serverRepository.currentUser.value?.let { user ->
|
serverRepository.currentUser.value?.let { user ->
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launchIO {
|
||||||
val libraryDisplayInfo =
|
val libraryDisplayInfo =
|
||||||
LibraryDisplayInfo(
|
LibraryDisplayInfo(
|
||||||
userId = user.rowId,
|
userId = user.rowId,
|
||||||
|
|
@ -252,8 +268,7 @@ class CollectionFolderViewModel
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
if (resetState) {
|
if (resetState) {
|
||||||
pager.value = listOf()
|
loading.value = DataLoadingState.Loading
|
||||||
loading.value = LoadingState.Loading
|
|
||||||
}
|
}
|
||||||
backgroundLoading.value = LoadingState.Loading
|
backgroundLoading.value = LoadingState.Loading
|
||||||
this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection
|
this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection
|
||||||
|
|
@ -264,8 +279,7 @@ class CollectionFolderViewModel
|
||||||
createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init()
|
createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init()
|
||||||
if (newPager.isNotEmpty()) newPager.getBlocking(0)
|
if (newPager.isNotEmpty()) newPager.getBlocking(0)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
pager.value = newPager
|
loading.value = DataLoadingState.Success(newPager)
|
||||||
loading.value = LoadingState.Success
|
|
||||||
backgroundLoading.value = LoadingState.Success
|
backgroundLoading.value = LoadingState.Success
|
||||||
}
|
}
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
|
|
@ -276,8 +290,7 @@ class CollectionFolderViewModel
|
||||||
filter,
|
filter,
|
||||||
)
|
)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
loading.value = LoadingState.Error(ex)
|
loading.value = DataLoadingState.Error(ex)
|
||||||
pager.value = listOf()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -288,10 +301,51 @@ class CollectionFolderViewModel
|
||||||
recursive: Boolean,
|
recursive: Boolean,
|
||||||
filter: GetItemsFilter,
|
filter: GetItemsFilter,
|
||||||
useSeriesForPrimary: Boolean,
|
useSeriesForPrimary: Boolean,
|
||||||
): ApiRequestPager<out Any> {
|
): ApiRequestPager<out Any> =
|
||||||
val item = item.value
|
when (filter.override) {
|
||||||
return when (filter.override) {
|
|
||||||
GetItemsFilterOverride.NONE -> {
|
GetItemsFilterOverride.NONE -> {
|
||||||
|
val request =
|
||||||
|
createGetItemsRequest(
|
||||||
|
sortAndDirection = sortAndDirection,
|
||||||
|
recursive = recursive,
|
||||||
|
filter = filter,
|
||||||
|
)
|
||||||
|
val newPager =
|
||||||
|
ApiRequestPager(
|
||||||
|
api,
|
||||||
|
request,
|
||||||
|
GetItemsRequestHandler,
|
||||||
|
viewModelScope,
|
||||||
|
useSeriesForPrimary = useSeriesForPrimary,
|
||||||
|
)
|
||||||
|
newPager
|
||||||
|
}
|
||||||
|
|
||||||
|
GetItemsFilterOverride.PERSON -> {
|
||||||
|
val request =
|
||||||
|
filter.applyTo(
|
||||||
|
GetPersonsRequest(
|
||||||
|
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val newPager =
|
||||||
|
ApiRequestPager(
|
||||||
|
api,
|
||||||
|
request,
|
||||||
|
GetPersonsHandler,
|
||||||
|
viewModelScope,
|
||||||
|
useSeriesForPrimary = useSeriesForPrimary,
|
||||||
|
)
|
||||||
|
newPager
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createGetItemsRequest(
|
||||||
|
sortAndDirection: SortAndDirection,
|
||||||
|
recursive: Boolean,
|
||||||
|
filter: GetItemsFilter,
|
||||||
|
): GetItemsRequest {
|
||||||
|
val item = item.value
|
||||||
val includeItemTypes =
|
val includeItemTypes =
|
||||||
item
|
item
|
||||||
?.data
|
?.data
|
||||||
|
|
@ -333,35 +387,7 @@ class CollectionFolderViewModel
|
||||||
fields = SlimItemFields,
|
fields = SlimItemFields,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val newPager =
|
return request
|
||||||
ApiRequestPager(
|
|
||||||
api,
|
|
||||||
request,
|
|
||||||
GetItemsRequestHandler,
|
|
||||||
viewModelScope,
|
|
||||||
useSeriesForPrimary = useSeriesForPrimary,
|
|
||||||
)
|
|
||||||
newPager
|
|
||||||
}
|
|
||||||
|
|
||||||
GetItemsFilterOverride.PERSON -> {
|
|
||||||
val request =
|
|
||||||
filter.applyTo(
|
|
||||||
GetPersonsRequest(
|
|
||||||
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val newPager =
|
|
||||||
ApiRequestPager(
|
|
||||||
api,
|
|
||||||
request,
|
|
||||||
GetPersonsHandler,
|
|
||||||
viewModelScope,
|
|
||||||
useSeriesForPrimary = useSeriesForPrimary,
|
|
||||||
)
|
|
||||||
newPager
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> =
|
suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> =
|
||||||
|
|
@ -441,27 +467,26 @@ class CollectionFolderViewModel
|
||||||
|
|
||||||
suspend fun positionOfLetter(letter: Char): Int? =
|
suspend fun positionOfLetter(letter: Char): Int? =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
item.value?.let { item ->
|
val sort = sortAndDirection.value
|
||||||
val includeItemTypes =
|
val filter = filter.value
|
||||||
when (item.data.collectionType) {
|
if (sort == null || filter == null) {
|
||||||
CollectionType.MOVIES -> listOf(BaseItemKind.MOVIE)
|
return@withContext null
|
||||||
CollectionType.TVSHOWS -> listOf(BaseItemKind.SERIES)
|
|
||||||
CollectionType.HOMEVIDEOS -> listOf(BaseItemKind.VIDEO)
|
|
||||||
else -> listOf()
|
|
||||||
}
|
}
|
||||||
val request =
|
val request =
|
||||||
GetItemsRequest(
|
createGetItemsRequest(
|
||||||
parentId = item.id,
|
sortAndDirection = sort,
|
||||||
includeItemTypes = includeItemTypes,
|
recursive = recursive,
|
||||||
|
filter = filter,
|
||||||
|
).copy(
|
||||||
|
enableImageTypes = null,
|
||||||
|
fields = null,
|
||||||
nameLessThan = letter.toString(),
|
nameLessThan = letter.toString(),
|
||||||
limit = 0,
|
limit = 0,
|
||||||
enableTotalRecordCount = true,
|
enableTotalRecordCount = true,
|
||||||
recursive = true,
|
|
||||||
)
|
)
|
||||||
val result by GetItemsRequestHandler.execute(api, request)
|
val result by GetItemsRequestHandler.execute(api, request)
|
||||||
result.totalRecordCount
|
result.totalRecordCount
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fun setWatched(
|
fun setWatched(
|
||||||
position: Int,
|
position: Int,
|
||||||
|
|
@ -469,7 +494,9 @@ class CollectionFolderViewModel
|
||||||
played: Boolean,
|
played: Boolean,
|
||||||
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||||
favoriteWatchManager.setWatched(itemId, played)
|
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(
|
fun setFavorite(
|
||||||
|
|
@ -478,7 +505,9 @@ class CollectionFolderViewModel
|
||||||
favorite: Boolean,
|
favorite: Boolean,
|
||||||
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||||
favoriteWatchManager.setFavorite(itemId, favorite)
|
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) {
|
fun updateBackdrop(item: BaseItem) {
|
||||||
|
|
@ -504,11 +533,13 @@ fun CollectionFolderGrid(
|
||||||
playEnabled: Boolean,
|
playEnabled: Boolean,
|
||||||
defaultViewOptions: ViewOptions,
|
defaultViewOptions: ViewOptions,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
viewModelKey: String? = itemId.toServerString(),
|
||||||
initialSortAndDirection: SortAndDirection? = null,
|
initialSortAndDirection: SortAndDirection? = null,
|
||||||
showTitle: Boolean = true,
|
showTitle: Boolean = true,
|
||||||
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
||||||
useSeriesForPrimary: Boolean = true,
|
useSeriesForPrimary: Boolean = true,
|
||||||
filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions,
|
filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions,
|
||||||
|
focusRequesterOnEmpty: FocusRequester? = null,
|
||||||
) = CollectionFolderGrid(
|
) = CollectionFolderGrid(
|
||||||
preferences,
|
preferences,
|
||||||
itemId.toServerString(),
|
itemId.toServerString(),
|
||||||
|
|
@ -517,6 +548,7 @@ fun CollectionFolderGrid(
|
||||||
onClickItem,
|
onClickItem,
|
||||||
sortOptions,
|
sortOptions,
|
||||||
playEnabled,
|
playEnabled,
|
||||||
|
viewModelKey = viewModelKey,
|
||||||
defaultViewOptions = defaultViewOptions,
|
defaultViewOptions = defaultViewOptions,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
initialSortAndDirection = initialSortAndDirection,
|
initialSortAndDirection = initialSortAndDirection,
|
||||||
|
|
@ -524,6 +556,7 @@ fun CollectionFolderGrid(
|
||||||
positionCallback = positionCallback,
|
positionCallback = positionCallback,
|
||||||
useSeriesForPrimary = useSeriesForPrimary,
|
useSeriesForPrimary = useSeriesForPrimary,
|
||||||
filterOptions = filterOptions,
|
filterOptions = filterOptions,
|
||||||
|
focusRequesterOnEmpty = focusRequesterOnEmpty,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -537,31 +570,34 @@ fun CollectionFolderGrid(
|
||||||
playEnabled: Boolean,
|
playEnabled: Boolean,
|
||||||
defaultViewOptions: ViewOptions,
|
defaultViewOptions: ViewOptions,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: CollectionFolderViewModel = hiltViewModel(key = itemId),
|
viewModelKey: String? = itemId,
|
||||||
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
|
||||||
initialSortAndDirection: SortAndDirection? = null,
|
initialSortAndDirection: SortAndDirection? = null,
|
||||||
showTitle: Boolean = true,
|
showTitle: Boolean = true,
|
||||||
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
||||||
useSeriesForPrimary: Boolean = true,
|
useSeriesForPrimary: Boolean = true,
|
||||||
filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions,
|
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
|
val context = LocalContext.current
|
||||||
OneTimeLaunchedEffect {
|
|
||||||
viewModel.init(
|
|
||||||
itemId,
|
|
||||||
initialSortAndDirection,
|
|
||||||
recursive,
|
|
||||||
initialFilter,
|
|
||||||
useSeriesForPrimary,
|
|
||||||
defaultViewOptions,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val sortAndDirection by viewModel.sortAndDirection.observeAsState(SortAndDirection.DEFAULT)
|
val sortAndDirection by viewModel.sortAndDirection.observeAsState(SortAndDirection.DEFAULT)
|
||||||
val filter by viewModel.filter.observeAsState(initialFilter.filter)
|
val filter by viewModel.filter.observeAsState(initialFilter.filter)
|
||||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||||
val backgroundLoading by viewModel.backgroundLoading.observeAsState(LoadingState.Loading)
|
val backgroundLoading by viewModel.backgroundLoading.observeAsState(LoadingState.Loading)
|
||||||
val item by viewModel.item.observeAsState()
|
val item by viewModel.item.observeAsState()
|
||||||
val pager by viewModel.pager.observeAsState()
|
|
||||||
val viewOptions by viewModel.viewOptions.observeAsState(defaultViewOptions)
|
val viewOptions by viewModel.viewOptions.observeAsState(defaultViewOptions)
|
||||||
|
|
||||||
var moreDialog by remember { mutableStateOf<Optional<PositionItem>>(Optional.absent()) }
|
var moreDialog by remember { mutableStateOf<Optional<PositionItem>>(Optional.absent()) }
|
||||||
|
|
@ -569,18 +605,15 @@ fun CollectionFolderGrid(
|
||||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||||
|
|
||||||
when (val state = loading) {
|
when (val state = loading) {
|
||||||
is LoadingState.Error -> {
|
DataLoadingState.Loading,
|
||||||
ErrorMessage(state)
|
DataLoadingState.Pending,
|
||||||
}
|
|
||||||
|
|
||||||
LoadingState.Loading,
|
|
||||||
LoadingState.Pending,
|
|
||||||
-> {
|
-> {
|
||||||
LoadingPage()
|
LoadingPage()
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Success -> {
|
is DataLoadingState.Error,
|
||||||
pager?.let { pager ->
|
is DataLoadingState.Success<*>,
|
||||||
|
-> {
|
||||||
val title =
|
val title =
|
||||||
initialFilter.nameOverride
|
initialFilter.nameOverride
|
||||||
?: item?.name
|
?: item?.name
|
||||||
|
|
@ -589,11 +622,13 @@ fun CollectionFolderGrid(
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
CollectionFolderGridContent(
|
CollectionFolderGridContent(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
|
initialPosition = viewModel.position,
|
||||||
item = item,
|
item = item,
|
||||||
title = title,
|
title = title,
|
||||||
pager = pager,
|
loadingState = state as DataLoadingState<List<BaseItem?>>,
|
||||||
sortAndDirection = sortAndDirection!!,
|
sortAndDirection = sortAndDirection!!,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
focusRequesterOnEmpty = focusRequesterOnEmpty,
|
||||||
onClickItem = onClickItem,
|
onClickItem = onClickItem,
|
||||||
onLongClickItem = { position, item ->
|
onLongClickItem = { position, item ->
|
||||||
moreDialog.makePresent(PositionItem(position, item))
|
moreDialog.makePresent(PositionItem(position, item))
|
||||||
|
|
@ -611,7 +646,10 @@ fun CollectionFolderGrid(
|
||||||
},
|
},
|
||||||
showTitle = showTitle,
|
showTitle = showTitle,
|
||||||
sortOptions = sortOptions,
|
sortOptions = sortOptions,
|
||||||
positionCallback = positionCallback,
|
positionCallback = { columns, position ->
|
||||||
|
viewModel.position = position
|
||||||
|
positionCallback?.invoke(columns, position)
|
||||||
|
},
|
||||||
letterPosition = { viewModel.positionOfLetter(it) ?: -1 },
|
letterPosition = { viewModel.positionOfLetter(it) ?: -1 },
|
||||||
viewOptions = viewOptions,
|
viewOptions = viewOptions,
|
||||||
defaultViewOptions = defaultViewOptions,
|
defaultViewOptions = defaultViewOptions,
|
||||||
|
|
@ -656,7 +694,6 @@ fun CollectionFolderGrid(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
moreDialog.compose { (position, item) ->
|
moreDialog.compose { (position, item) ->
|
||||||
DialogPopup(
|
DialogPopup(
|
||||||
showDialog = true,
|
showDialog = true,
|
||||||
|
|
@ -713,7 +750,7 @@ fun CollectionFolderGridContent(
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
item: BaseItem?,
|
item: BaseItem?,
|
||||||
title: String,
|
title: String,
|
||||||
pager: List<BaseItem?>,
|
loadingState: DataLoadingState<List<BaseItem?>>,
|
||||||
sortAndDirection: SortAndDirection,
|
sortAndDirection: SortAndDirection,
|
||||||
onClickItem: (Int, BaseItem) -> Unit,
|
onClickItem: (Int, BaseItem) -> Unit,
|
||||||
onLongClickItem: (Int, BaseItem) -> Unit,
|
onLongClickItem: (Int, BaseItem) -> Unit,
|
||||||
|
|
@ -728,25 +765,35 @@ fun CollectionFolderGridContent(
|
||||||
onClickPlayAll: (shuffle: Boolean) -> Unit,
|
onClickPlayAll: (shuffle: Boolean) -> Unit,
|
||||||
onClickPlay: (Int, BaseItem) -> Unit,
|
onClickPlay: (Int, BaseItem) -> Unit,
|
||||||
onChangeBackdrop: (BaseItem) -> Unit,
|
onChangeBackdrop: (BaseItem) -> Unit,
|
||||||
|
initialPosition: Int,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
showTitle: Boolean = true,
|
showTitle: Boolean = true,
|
||||||
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
||||||
currentFilter: GetItemsFilter = GetItemsFilter(),
|
currentFilter: GetItemsFilter = GetItemsFilter(),
|
||||||
filterOptions: List<ItemFilterBy<*>> = listOf(),
|
filterOptions: List<ItemFilterBy<*>> = listOf(),
|
||||||
onFilterChange: (GetItemsFilter) -> Unit = {},
|
onFilterChange: (GetItemsFilter) -> Unit = {},
|
||||||
|
focusRequesterOnEmpty: FocusRequester? = null,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
val pager = (loadingState as? DataLoadingState.Success)?.data
|
||||||
var showHeader by rememberSaveable { mutableStateOf(true) }
|
var showHeader by rememberSaveable { mutableStateOf(true) }
|
||||||
var showViewOptions by rememberSaveable { mutableStateOf(false) }
|
var showViewOptions by rememberSaveable { mutableStateOf(false) }
|
||||||
var viewOptions by remember { mutableStateOf(viewOptions) }
|
var viewOptions by remember { mutableStateOf(viewOptions) }
|
||||||
|
val headerRowFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
val gridFocusRequester = 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 backdropImageUrl by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
var position by rememberInt(0)
|
var position by rememberInt(initialPosition)
|
||||||
val focusedItem = pager.getOrNull(position)
|
val focusedItem = pager?.getOrNull(position)
|
||||||
if (viewOptions.showDetails) {
|
if (viewOptions.showDetails) {
|
||||||
LaunchedEffect(focusedItem) {
|
LaunchedEffect(focusedItem) {
|
||||||
focusedItem?.let(onChangeBackdrop)
|
focusedItem?.let(onChangeBackdrop)
|
||||||
|
|
@ -759,7 +806,7 @@ fun CollectionFolderGridContent(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
showHeader,
|
showHeader || loadingState !is DataLoadingState.Success,
|
||||||
enter = slideInVertically() + fadeIn(),
|
enter = slideInVertically() + fadeIn(),
|
||||||
exit = slideOutVertically() + fadeOut(),
|
exit = slideOutVertically() + fadeOut(),
|
||||||
) {
|
) {
|
||||||
|
|
@ -786,7 +833,8 @@ fun CollectionFolderGridContent(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.padding(start = 16.dp, end = endPadding)
|
.padding(start = 16.dp, end = endPadding)
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth()
|
||||||
|
.focusRequester(headerRowFocusRequester),
|
||||||
) {
|
) {
|
||||||
if (sortOptions.isNotEmpty() || filterOptions.isNotEmpty()) {
|
if (sortOptions.isNotEmpty() || filterOptions.isNotEmpty()) {
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -819,7 +867,7 @@ fun CollectionFolderGridContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (playEnabled) {
|
if (playEnabled && pager?.isNotEmpty() == true) {
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
|
@ -851,8 +899,21 @@ fun CollectionFolderGridContent(
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
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(
|
CardGrid(
|
||||||
pager = pager,
|
pager = state.data,
|
||||||
onClickItem = onClickItem,
|
onClickItem = onClickItem,
|
||||||
onLongClickItem = onLongClickItem,
|
onLongClickItem = onLongClickItem,
|
||||||
onClickPlay = onClickPlay,
|
onClickPlay = onClickPlay,
|
||||||
|
|
@ -861,7 +922,7 @@ fun CollectionFolderGridContent(
|
||||||
showJumpButtons = false, // TODO add preference
|
showJumpButtons = false, // TODO add preference
|
||||||
showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME,
|
showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
initialPosition = 0,
|
initialPosition = initialPosition,
|
||||||
positionCallback = { columns, newPosition ->
|
positionCallback = { columns, newPosition ->
|
||||||
showHeader = newPosition < columns
|
showHeader = newPosition < columns
|
||||||
position = newPosition
|
position = newPosition
|
||||||
|
|
@ -895,6 +956,8 @@ fun CollectionFolderGridContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class PositionItem(
|
data class PositionItem(
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import androidx.compose.foundation.gestures.scrollBy
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxScope
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
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.data.model.TrackIndex
|
||||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.SimpleMediaStream
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -83,6 +85,7 @@ data class DialogItem(
|
||||||
val leadingContent: @Composable (BoxScope.() -> Unit)? = null,
|
val leadingContent: @Composable (BoxScope.() -> Unit)? = null,
|
||||||
val trailingContent: @Composable (() -> Unit)? = null,
|
val trailingContent: @Composable (() -> Unit)? = null,
|
||||||
val enabled: Boolean = true,
|
val enabled: Boolean = true,
|
||||||
|
val selected: Boolean = false,
|
||||||
) : DialogItemEntry {
|
) : DialogItemEntry {
|
||||||
constructor(
|
constructor(
|
||||||
@StringRes text: Int,
|
@StringRes text: Int,
|
||||||
|
|
@ -239,24 +242,24 @@ fun DialogPopupContent(
|
||||||
) {
|
) {
|
||||||
val elevatedContainerColor =
|
val elevatedContainerColor =
|
||||||
MaterialTheme.colorScheme.surfaceColorAtElevation(elevation)
|
MaterialTheme.colorScheme.surfaceColorAtElevation(elevation)
|
||||||
LazyColumn(
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier =
|
modifier =
|
||||||
modifier
|
modifier
|
||||||
// .widthIn(min = 520.dp, max = 300.dp)
|
|
||||||
// .dialogFocusable()
|
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
this.clip = true
|
this.clip = true
|
||||||
this.shape = RoundedCornerShape(28.0.dp)
|
this.shape = RoundedCornerShape(28.0.dp)
|
||||||
}.drawBehind { drawRect(color = elevatedContainerColor) }
|
}.drawBehind { drawRect(color = elevatedContainerColor) }
|
||||||
.padding(PaddingValues(24.dp)),
|
.padding(PaddingValues(24.dp)),
|
||||||
) {
|
) {
|
||||||
stickyHeader {
|
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
}
|
LazyColumn(
|
||||||
|
modifier = Modifier,
|
||||||
|
) {
|
||||||
items(dialogItems) {
|
items(dialogItems) {
|
||||||
when (it) {
|
when (it) {
|
||||||
is DialogItemDivider -> {
|
is DialogItemDivider -> {
|
||||||
|
|
@ -265,7 +268,7 @@ fun DialogPopupContent(
|
||||||
|
|
||||||
is DialogItem -> {
|
is DialogItem -> {
|
||||||
ListItem(
|
ListItem(
|
||||||
selected = false,
|
selected = it.selected,
|
||||||
enabled = !waiting && it.enabled,
|
enabled = !waiting && it.enabled,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (dismissOnClick) {
|
if (dismissOnClick) {
|
||||||
|
|
@ -284,6 +287,7 @@ fun DialogPopupContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -500,6 +504,7 @@ fun resourceFor(type: MediaStreamType): Int =
|
||||||
fun chooseStream(
|
fun chooseStream(
|
||||||
context: Context,
|
context: Context,
|
||||||
streams: List<MediaStream>,
|
streams: List<MediaStream>,
|
||||||
|
currentIndex: Int?,
|
||||||
type: MediaStreamType,
|
type: MediaStreamType,
|
||||||
onClick: (Int) -> Unit,
|
onClick: (Int) -> Unit,
|
||||||
): DialogParams =
|
): DialogParams =
|
||||||
|
|
@ -511,6 +516,10 @@ fun chooseStream(
|
||||||
if (type == MediaStreamType.SUBTITLE) {
|
if (type == MediaStreamType.SUBTITLE) {
|
||||||
add(
|
add(
|
||||||
DialogItem(
|
DialogItem(
|
||||||
|
selected = currentIndex == null,
|
||||||
|
leadingContent = {
|
||||||
|
SelectedLeadingContent(currentIndex == null)
|
||||||
|
},
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
Text(text = stringResource(R.string.none))
|
Text(text = stringResource(R.string.none))
|
||||||
},
|
},
|
||||||
|
|
@ -519,15 +528,30 @@ fun chooseStream(
|
||||||
onClick = { onClick.invoke(TrackIndex.DISABLED) },
|
onClick = { onClick.invoke(TrackIndex.DISABLED) },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(text = stringResource(R.string.only_forced_subtitles))
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
},
|
||||||
|
onClick = { onClick.invoke(TrackIndex.ONLY_FORCED) },
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
addAll(
|
addAll(
|
||||||
streams.filter { it.type == type }.mapIndexed { index, stream ->
|
streams.filter { it.type == type }.mapIndexed { index, stream ->
|
||||||
val title = stream.displayTitle ?: stream.title ?: ""
|
val simpleStream = SimpleMediaStream.from(context, stream, true)
|
||||||
DialogItem(
|
DialogItem(
|
||||||
|
selected = currentIndex == stream.index,
|
||||||
|
leadingContent = {
|
||||||
|
SelectedLeadingContent(currentIndex == stream.index)
|
||||||
|
},
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
Text(text = title)
|
Text(text = simpleStream.streamTitle ?: simpleStream.displayTitle)
|
||||||
},
|
},
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
|
if (simpleStream.streamTitle != null) Text(text = simpleStream.displayTitle)
|
||||||
},
|
},
|
||||||
onClick = { onClick.invoke(stream.index) },
|
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.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.graphics.Color
|
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.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.ViewModel
|
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.GetItemsRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
|
|
@ -51,26 +58,32 @@ import org.jellyfin.sdk.model.api.request.GetGenresRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel(assistedFactory = GenreViewModel.Factory::class)
|
||||||
class GenreViewModel
|
class GenreViewModel
|
||||||
@Inject
|
@AssistedInject
|
||||||
constructor(
|
constructor(
|
||||||
private val api: ApiClient,
|
private val api: ApiClient,
|
||||||
private val imageUrlService: ImageUrlService,
|
private val imageUrlService: ImageUrlService,
|
||||||
private val serverRepository: ServerRepository,
|
private val serverRepository: ServerRepository,
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
|
@Assisted private val itemId: UUID,
|
||||||
|
@Assisted private val includeItemTypes: List<BaseItemKind>?,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private lateinit var itemId: UUID
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(
|
||||||
|
itemId: UUID,
|
||||||
|
includeItemTypes: List<BaseItemKind>?,
|
||||||
|
): GenreViewModel
|
||||||
|
}
|
||||||
|
|
||||||
val item = MutableLiveData<BaseItem?>(null)
|
val item = MutableLiveData<BaseItem?>(null)
|
||||||
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||||
val genres = MutableLiveData<List<Genre>>(listOf())
|
val genres = MutableLiveData<List<Genre>>(listOf())
|
||||||
|
|
||||||
fun init(itemId: UUID) {
|
fun init(cardWidthPx: Int) {
|
||||||
loading.value = LoadingState.Loading
|
loading.value = LoadingState.Loading
|
||||||
this.itemId = itemId
|
|
||||||
viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to fetch genres")) {
|
viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to fetch genres")) {
|
||||||
val item =
|
val item =
|
||||||
api.userLibraryApi.getItem(itemId = itemId).content.let {
|
api.userLibraryApi.getItem(itemId = itemId).content.let {
|
||||||
|
|
@ -82,6 +95,7 @@ class GenreViewModel
|
||||||
userId = serverRepository.currentUser.value?.id,
|
userId = serverRepository.currentUser.value?.id,
|
||||||
parentId = itemId,
|
parentId = itemId,
|
||||||
fields = SlimItemFields,
|
fields = SlimItemFields,
|
||||||
|
includeItemTypes = includeItemTypes,
|
||||||
)
|
)
|
||||||
val genres =
|
val genres =
|
||||||
GetGenresRequestHandler
|
GetGenresRequestHandler
|
||||||
|
|
@ -90,13 +104,11 @@ class GenreViewModel
|
||||||
.map {
|
.map {
|
||||||
Genre(it.id, it.name ?: "", null, Color.Black)
|
Genre(it.id, it.name ?: "", null, Color.Black)
|
||||||
}
|
}
|
||||||
// val pager = ApiRequestPager(api, request, GetGenresRequestHandler, viewModelScope).init()
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
this@GenreViewModel.genres.value = genres
|
this@GenreViewModel.genres.value = genres
|
||||||
loading.value = LoadingState.Success
|
loading.value = LoadingState.Success
|
||||||
}
|
}
|
||||||
// val excludeItemIds = mutableSetOf<UUID>()
|
val genreToUrl = ConcurrentHashMap<UUID, String?>()
|
||||||
val genreToUrl = ConcurrentHashMap<UUID, String>()
|
|
||||||
val semaphore = Semaphore(4)
|
val semaphore = Semaphore(4)
|
||||||
genres
|
genres
|
||||||
.map { genre ->
|
.map { genre ->
|
||||||
|
|
@ -107,33 +119,29 @@ class GenreViewModel
|
||||||
.execute(
|
.execute(
|
||||||
api,
|
api,
|
||||||
GetItemsRequest(
|
GetItemsRequest(
|
||||||
// excludeItemIds = excludeItemIds,
|
|
||||||
parentId = itemId,
|
parentId = itemId,
|
||||||
recursive = true,
|
recursive = true,
|
||||||
limit = 1,
|
limit = 1,
|
||||||
sortBy = listOf(ItemSortBy.RANDOM),
|
sortBy = listOf(ItemSortBy.RANDOM),
|
||||||
fields = listOf(ItemFields.GENRES),
|
fields = listOf(ItemFields.GENRES),
|
||||||
imageTypes = listOf(ImageType.THUMB),
|
imageTypes = listOf(ImageType.BACKDROP),
|
||||||
imageTypeLimit = 1,
|
imageTypeLimit = 1,
|
||||||
includeItemTypes =
|
includeItemTypes = includeItemTypes,
|
||||||
listOf(
|
|
||||||
BaseItemKind.MOVIE,
|
|
||||||
BaseItemKind.SERIES,
|
|
||||||
),
|
|
||||||
genreIds = listOf(genre.id),
|
genreIds = listOf(genre.id),
|
||||||
enableTotalRecordCount = false,
|
enableTotalRecordCount = false,
|
||||||
),
|
),
|
||||||
).content.items
|
).content.items
|
||||||
.firstOrNull()
|
.firstOrNull()
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
// excludeItemIds.add(item.id)
|
|
||||||
genreToUrl[genre.id] =
|
genreToUrl[genre.id] =
|
||||||
imageUrlService.getItemImageUrl(
|
imageUrlService.getItemImageUrl(
|
||||||
item.id,
|
itemId = item.id,
|
||||||
item.type,
|
itemType = item.type,
|
||||||
null,
|
seriesId = null,
|
||||||
false,
|
useSeriesForPrimary = true,
|
||||||
ImageType.THUMB,
|
imageType = ImageType.BACKDROP,
|
||||||
|
imageTags = item.imageTags.orEmpty(),
|
||||||
|
fillWidth = cardWidthPx,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -164,11 +172,12 @@ class GenreViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Genre(
|
data class Genre(
|
||||||
override val id: UUID,
|
val id: UUID,
|
||||||
val name: String,
|
val name: String,
|
||||||
val imageUrl: String?,
|
val imageUrl: String?,
|
||||||
val color: Color,
|
val color: Color,
|
||||||
) : CardGridItem {
|
) : CardGridItem {
|
||||||
|
override val gridId: String get() = id.toString()
|
||||||
override val playable: Boolean = false
|
override val playable: Boolean = false
|
||||||
override val sortName: String get() = name
|
override val sortName: String get() = name
|
||||||
}
|
}
|
||||||
|
|
@ -176,11 +185,30 @@ data class Genre(
|
||||||
@Composable
|
@Composable
|
||||||
fun GenreCardGrid(
|
fun GenreCardGrid(
|
||||||
itemId: UUID,
|
itemId: UUID,
|
||||||
|
includeItemTypes: List<BaseItemKind>?,
|
||||||
modifier: Modifier = Modifier,
|
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 {
|
OneTimeLaunchedEffect {
|
||||||
viewModel.init(itemId)
|
viewModel.init(cardWidthPx)
|
||||||
}
|
}
|
||||||
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
|
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
|
||||||
val genres by viewModel.genres.observeAsState(listOf())
|
val genres by viewModel.genres.observeAsState(listOf())
|
||||||
|
|
@ -214,7 +242,11 @@ fun GenreCardGrid(
|
||||||
genre.name,
|
genre.name,
|
||||||
item?.title,
|
item?.title,
|
||||||
).joinToString(" "),
|
).joinToString(" "),
|
||||||
filter = GetItemsFilter(genres = listOf(genre.id)),
|
filter =
|
||||||
|
GetItemsFilter(
|
||||||
|
genres = listOf(genre.id),
|
||||||
|
includeItemTypes = includeItemTypes,
|
||||||
|
),
|
||||||
useSavedLibraryDisplayInfo = false,
|
useSavedLibraryDisplayInfo = false,
|
||||||
),
|
),
|
||||||
recursive = true,
|
recursive = true,
|
||||||
|
|
@ -231,7 +263,8 @@ fun GenreCardGrid(
|
||||||
initialPosition = 0,
|
initialPosition = 0,
|
||||||
positionCallback = { columns, position ->
|
positionCallback = { columns, position ->
|
||||||
},
|
},
|
||||||
columns = 4,
|
columns = columns,
|
||||||
|
spacing = spacing,
|
||||||
cardContent = { item: Genre?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier ->
|
cardContent = { item: Genre?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier ->
|
||||||
GenreCard(
|
GenreCard(
|
||||||
genre = item,
|
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.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
|
@ -40,6 +43,7 @@ import androidx.tv.material3.Icon
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.R
|
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.FontAwesome
|
||||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||||
|
|
@ -59,10 +63,12 @@ fun ExpandablePlayButtons(
|
||||||
resumePosition: Duration,
|
resumePosition: Duration,
|
||||||
watched: Boolean,
|
watched: Boolean,
|
||||||
favorite: Boolean,
|
favorite: Boolean,
|
||||||
|
trailers: List<Trailer>?,
|
||||||
playOnClick: (position: Duration) -> Unit,
|
playOnClick: (position: Duration) -> Unit,
|
||||||
watchOnClick: () -> Unit,
|
watchOnClick: () -> Unit,
|
||||||
favoriteOnClick: () -> Unit,
|
favoriteOnClick: () -> Unit,
|
||||||
moreOnClick: () -> Unit,
|
moreOnClick: () -> Unit,
|
||||||
|
trailerOnClick: (Trailer) -> Unit,
|
||||||
buttonOnFocusChanged: (FocusState) -> Unit,
|
buttonOnFocusChanged: (FocusState) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
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
|
// More button
|
||||||
item("more") {
|
item("more") {
|
||||||
ExpandablePlayButton(
|
ExpandablePlayButton(
|
||||||
|
|
@ -163,10 +179,12 @@ fun ExpandablePlayButton(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
mirrorIcon: Boolean = false,
|
mirrorIcon: Boolean = false,
|
||||||
|
enabled: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val isFocused = interactionSource.collectIsFocusedAsState().value
|
val isFocused = interactionSource.collectIsFocusedAsState().value
|
||||||
Button(
|
Button(
|
||||||
onClick = { onClick.invoke(resume) },
|
onClick = { onClick.invoke(resume) },
|
||||||
|
enabled = enabled,
|
||||||
modifier =
|
modifier =
|
||||||
modifier.requiredSizeIn(
|
modifier.requiredSizeIn(
|
||||||
minWidth = MinButtonSize,
|
minWidth = MinButtonSize,
|
||||||
|
|
@ -213,10 +231,12 @@ fun ExpandableFaButton(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
iconColor: Color = Color.Unspecified,
|
iconColor: Color = Color.Unspecified,
|
||||||
|
enabled: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val isFocused = interactionSource.collectIsFocusedAsState().value
|
val isFocused = interactionSource.collectIsFocusedAsState().value
|
||||||
Button(
|
Button(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
modifier =
|
modifier =
|
||||||
modifier.requiredSizeIn(
|
modifier.requiredSizeIn(
|
||||||
minWidth = MinButtonSize,
|
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
|
@PreviewTvSpec
|
||||||
@Composable
|
@Composable
|
||||||
private fun ExpandablePlayButtonsPreview() {
|
private fun ExpandablePlayButtonsPreview() {
|
||||||
|
|
@ -264,6 +320,8 @@ private fun ExpandablePlayButtonsPreview() {
|
||||||
favoriteOnClick = {},
|
favoriteOnClick = {},
|
||||||
moreOnClick = {},
|
moreOnClick = {},
|
||||||
buttonOnFocusChanged = {},
|
buttonOnFocusChanged = {},
|
||||||
|
trailers = listOf(),
|
||||||
|
trailerOnClick = {},
|
||||||
modifier = Modifier,
|
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.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.BackdropService
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.DatePlayedService
|
|
||||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||||
|
import com.github.damontecres.wholphin.services.LatestNextUpService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
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.setValueOnMain
|
||||||
import com.github.damontecres.wholphin.ui.toBaseItems
|
import com.github.damontecres.wholphin.ui.toBaseItems
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
|
|
@ -58,7 +57,7 @@ class RecommendedTvShowViewModel
|
||||||
private val api: ApiClient,
|
private val api: ApiClient,
|
||||||
private val serverRepository: ServerRepository,
|
private val serverRepository: ServerRepository,
|
||||||
private val preferencesDataStore: DataStore<AppPreferences>,
|
private val preferencesDataStore: DataStore<AppPreferences>,
|
||||||
private val datePlayedService: DatePlayedService,
|
private val lastestNextUpService: LatestNextUpService,
|
||||||
@Assisted val parentId: UUID,
|
@Assisted val parentId: UUID,
|
||||||
navigationManager: NavigationManager,
|
navigationManager: NavigationManager,
|
||||||
favoriteWatchManager: FavoriteWatchManager,
|
favoriteWatchManager: FavoriteWatchManager,
|
||||||
|
|
@ -126,9 +125,7 @@ class RecommendedTvShowViewModel
|
||||||
val nextUpItems = nextUpItemsDeferred.await()
|
val nextUpItems = nextUpItemsDeferred.await()
|
||||||
if (combineNextUp) {
|
if (combineNextUp) {
|
||||||
val combined =
|
val combined =
|
||||||
buildCombinedNextUp(
|
lastestNextUpService.buildCombined(
|
||||||
viewModelScope,
|
|
||||||
datePlayedService,
|
|
||||||
resumeItems,
|
resumeItems,
|
||||||
nextUpItems,
|
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
|
package com.github.damontecres.wholphin.ui.components
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.ui.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.api.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SeriesName(
|
fun SeriesName(
|
||||||
|
|
@ -41,7 +34,8 @@ fun EpisodeName(
|
||||||
text = episodeName ?: "",
|
text = episodeName ?: "",
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
maxLines = 2,
|
fontSize = 20.sp,
|
||||||
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
|
|
@ -52,52 +46,3 @@ fun EpisodeName(
|
||||||
episode: BaseItemDto?,
|
episode: BaseItemDto?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) = EpisodeName(episode?.episodeTitle ?: episode?.name, 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.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusProperties
|
import androidx.compose.ui.focus.focusProperties
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
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.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
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.theme.WholphinTheme
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TabRow(
|
fun TabRow(
|
||||||
selectedTabIndex: Int,
|
selectedTabIndex: Int,
|
||||||
tabs: List<String>,
|
tabs: List<String>,
|
||||||
|
focusRequesters: List<FocusRequester>,
|
||||||
onClick: (Int) -> Unit,
|
onClick: (Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val state = rememberLazyListState()
|
val state = rememberLazyListState()
|
||||||
LaunchedEffect(selectedTabIndex) {
|
LaunchedEffect(selectedTabIndex) {
|
||||||
|
if (selectedTabIndex >= 0) {
|
||||||
state.animateScrollToItem(selectedTabIndex, -(state.layoutInfo.viewportSize.width / 3.5).toInt())
|
state.animateScrollToItem(selectedTabIndex, -(state.layoutInfo.viewportSize.width / 3.5).toInt())
|
||||||
}
|
}
|
||||||
val focusRequesters = remember(tabs) { List(tabs.size) { FocusRequester() } }
|
}
|
||||||
var rowHasFocus by remember { mutableStateOf(false) }
|
var rowHasFocus by remember { mutableStateOf(false) }
|
||||||
LazyRow(
|
LazyRow(
|
||||||
state = state,
|
state = state,
|
||||||
|
|
@ -66,6 +71,7 @@ fun TabRow(
|
||||||
onEnter = {
|
onEnter = {
|
||||||
// If entering from left or right, use last or first tab
|
// If entering from left or right, use last or first tab
|
||||||
// Otherwise use the selected tab
|
// Otherwise use the selected tab
|
||||||
|
Timber.v("onEnter requestedFocusDirection=$requestedFocusDirection, selectedTabIndex=$selectedTabIndex")
|
||||||
val focusRequester =
|
val focusRequester =
|
||||||
if (requestedFocusDirection == FocusDirection.Left) {
|
if (requestedFocusDirection == FocusDirection.Left) {
|
||||||
focusRequesters.lastOrNull()
|
focusRequesters.lastOrNull()
|
||||||
|
|
@ -181,6 +187,7 @@ private fun TabRowPreview() {
|
||||||
TabRow(
|
TabRow(
|
||||||
selectedTabIndex = 1,
|
selectedTabIndex = 1,
|
||||||
tabs = listOf("Tab 1", "Tab 2", "Tab 3"),
|
tabs = listOf("Tab 1", "Tab 2", "Tab 3"),
|
||||||
|
focusRequesters = listOf(),
|
||||||
onClick = {},
|
onClick = {},
|
||||||
)
|
)
|
||||||
Tab(
|
Tab(
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.components
|
||||||
import androidx.compose.foundation.layout.BoxScope
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -13,8 +14,9 @@ import com.github.damontecres.wholphin.ui.util.LocalClock
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun BoxScope.TimeDisplay(modifier: Modifier = Modifier) {
|
fun BoxScope.TimeDisplay(modifier: Modifier = Modifier) {
|
||||||
|
val timeString by LocalClock.current.timeString
|
||||||
Text(
|
Text(
|
||||||
text = LocalClock.current.timeString,
|
text = timeString,
|
||||||
fontSize = 18.sp,
|
fontSize = 18.sp,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
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
|
package com.github.damontecres.wholphin.ui.components
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
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.preferences.AppThemeColors
|
||||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
|
||||||
import com.github.damontecres.wholphin.ui.playback.audioStreamCount
|
import com.github.damontecres.wholphin.ui.playback.audioStreamCount
|
||||||
import com.github.damontecres.wholphin.ui.playback.embeddedSubtitleCount
|
import com.github.damontecres.wholphin.ui.playback.embeddedSubtitleCount
|
||||||
import com.github.damontecres.wholphin.ui.playback.externalSubtitlesCount
|
import com.github.damontecres.wholphin.ui.playback.externalSubtitlesCount
|
||||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
|
import com.github.damontecres.wholphin.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.languageName
|
||||||
import com.github.damontecres.wholphin.util.profile.Codec
|
|
||||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||||
import org.jellyfin.sdk.model.api.MediaStream
|
import org.jellyfin.sdk.model.api.MediaStream
|
||||||
import org.jellyfin.sdk.model.api.VideoRange
|
|
||||||
import org.jellyfin.sdk.model.api.VideoRangeType
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@NonRestartableComposable
|
@NonRestartableComposable
|
||||||
fun VideoStreamDetails(
|
fun VideoStreamDetails(
|
||||||
chosenStreams: ChosenStreams?,
|
chosenStreams: ChosenStreams?,
|
||||||
|
numberOfVersions: Int,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) = VideoStreamDetails(
|
) = VideoStreamDetails(
|
||||||
chosenStreams?.source,
|
chosenStreams?.source,
|
||||||
chosenStreams?.videoStream,
|
chosenStreams?.videoStream,
|
||||||
chosenStreams?.audioStream,
|
chosenStreams?.audioStream,
|
||||||
chosenStreams?.subtitleStream,
|
chosenStreams?.subtitleStream,
|
||||||
|
numberOfVersions,
|
||||||
modifier,
|
modifier,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -62,6 +64,7 @@ fun VideoStreamDetails(
|
||||||
videoStream: MediaStream?,
|
videoStream: MediaStream?,
|
||||||
audioStream: MediaStream?,
|
audioStream: MediaStream?,
|
||||||
subtitleStream: MediaStream?,
|
subtitleStream: MediaStream?,
|
||||||
|
numberOfVersions: Int = 0,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
@ -86,13 +89,16 @@ fun VideoStreamDetails(
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
val range = formatVideoRange(context, it.videoRange, it.videoRangeType, it.videoDoViTitle)
|
val range = formatVideoRange(context, it.videoRange, it.videoRangeType, it.videoDoViTitle)
|
||||||
listOfNotNull(
|
resName.concatWithSpace(range)
|
||||||
resName.concatWithSpace(range),
|
|
||||||
it.codec?.uppercase(),
|
|
||||||
)
|
|
||||||
}.orEmpty()
|
|
||||||
}
|
}
|
||||||
video.forEach {
|
}
|
||||||
|
video?.let {
|
||||||
|
StreamLabel(
|
||||||
|
text = it,
|
||||||
|
count = numberOfVersions,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
videoStream?.codec?.uppercase()?.let {
|
||||||
StreamLabel(it)
|
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
|
@Composable
|
||||||
fun StreamLabel(
|
fun StreamLabel(
|
||||||
text: String,
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
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.R
|
||||||
import com.github.damontecres.wholphin.ui.byteRateSuffixes
|
import com.github.damontecres.wholphin.ui.byteRateSuffixes
|
||||||
import com.github.damontecres.wholphin.ui.components.ScrollableDialog
|
import com.github.damontecres.wholphin.ui.components.ScrollableDialog
|
||||||
import com.github.damontecres.wholphin.ui.components.formatAudioCodec
|
|
||||||
import com.github.damontecres.wholphin.ui.components.formatSubtitleCodec
|
|
||||||
import com.github.damontecres.wholphin.ui.formatBytes
|
import com.github.damontecres.wholphin.ui.formatBytes
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||||
|
import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatAudioCodec
|
||||||
|
import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatSubtitleCodec
|
||||||
import com.github.damontecres.wholphin.util.languageName
|
import com.github.damontecres.wholphin.util.languageName
|
||||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||||
import org.jellyfin.sdk.model.api.MediaStream
|
import org.jellyfin.sdk.model.api.MediaStream
|
||||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
import org.jellyfin.sdk.model.api.VideoRange
|
import org.jellyfin.sdk.model.api.VideoRange
|
||||||
import org.jellyfin.sdk.model.api.VideoRangeType
|
import org.jellyfin.sdk.model.api.VideoRangeType
|
||||||
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
data class ItemDetailsDialogInfo(
|
data class ItemDetailsDialogInfo(
|
||||||
|
|
@ -54,48 +57,53 @@ fun ItemDetailsDialog(
|
||||||
val subtitleLabel = stringResource(R.string.subtitle)
|
val subtitleLabel = stringResource(R.string.subtitle)
|
||||||
val bitrateLabel = stringResource(R.string.bitrate)
|
val bitrateLabel = stringResource(R.string.bitrate)
|
||||||
val unknown = stringResource(R.string.unknown)
|
val unknown = stringResource(R.string.unknown)
|
||||||
|
val runtimeLabel = stringResource(R.string.runtime_sort)
|
||||||
|
|
||||||
ScrollableDialog(
|
ScrollableDialog(
|
||||||
onDismissRequest = onDismissRequest,
|
onDismissRequest = onDismissRequest,
|
||||||
width = 720.dp,
|
width = 680.dp,
|
||||||
maxHeight = 480.dp,
|
maxHeight = 440.dp,
|
||||||
itemSpacing = 4.dp,
|
itemSpacing = 8.dp,
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = info.title,
|
text = info.title,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
if (info.genres.isNotEmpty()) {
|
if (info.genres.isNotEmpty()) {
|
||||||
item {
|
|
||||||
Text(
|
Text(
|
||||||
text = info.genres.joinToString(", "),
|
text = info.genres.joinToString(", "),
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (info.overview.isNotNullOrBlank()) {
|
if (info.overview.isNotNullOrBlank()) {
|
||||||
item {
|
|
||||||
Text(
|
Text(
|
||||||
text = info.overview,
|
text = info.overview,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Show detailed media information for the selected source (first one if multiple)
|
// Show detailed media information for the selected source (first one if multiple)
|
||||||
info.files.forEachIndexed { index, source ->
|
info.files.forEachIndexed { index, source ->
|
||||||
source.mediaStreams?.letNotEmpty { mediaStreams ->
|
source.mediaStreams?.letNotEmpty { mediaStreams ->
|
||||||
item {
|
item {
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
|
|
||||||
// General file information
|
// General file information
|
||||||
item {
|
item {
|
||||||
val containerLabel = stringResource(R.string.container)
|
val containerLabel = stringResource(R.string.container)
|
||||||
MediaInfoSection(
|
MediaInfoSection(
|
||||||
title = stringResource(R.string.general),
|
title =
|
||||||
|
titleIndex(
|
||||||
|
stringResource(R.string.general),
|
||||||
|
index,
|
||||||
|
info.files.size,
|
||||||
|
),
|
||||||
items =
|
items =
|
||||||
buildList {
|
buildList {
|
||||||
source.container?.let { add(containerLabel to it) }
|
source.container?.let { add(containerLabel to it) }
|
||||||
|
|
@ -111,31 +119,38 @@ fun ItemDetailsDialog(
|
||||||
bitrateLabel to formatBytes(it, byteRateSuffixes),
|
bitrateLabel to formatBytes(it, byteRateSuffixes),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
source.runTimeTicks?.let {
|
||||||
|
add(runtimeLabel to it.ticks.toString())
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Video streams
|
// Video streams
|
||||||
items(mediaStreams.filter { it.type == MediaStreamType.VIDEO }) { stream ->
|
val videoStreams = mediaStreams.filter { it.type == MediaStreamType.VIDEO }
|
||||||
|
itemsIndexed(videoStreams) { index, stream ->
|
||||||
MediaInfoSection(
|
MediaInfoSection(
|
||||||
title = videoLabel,
|
title = titleIndex(videoLabel, index, videoStreams.size),
|
||||||
items = buildVideoStreamInfo(context, stream),
|
items = remember { buildVideoStreamInfo(context, stream) },
|
||||||
|
additional = remember { buildVideoStreamInfoAdditional(context, stream) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audio streams - display multiple per row
|
// Audio streams - display multiple per row
|
||||||
items(
|
val audioStreams = mediaStreams.filter { it.type == MediaStreamType.AUDIO }
|
||||||
mediaStreams
|
itemsIndexed(audioStreams.chunked(3)) { groupIndex, streamGroup ->
|
||||||
.filter { it.type == MediaStreamType.AUDIO }
|
|
||||||
.chunked(3),
|
|
||||||
) { streamGroup ->
|
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
streamGroup.forEach { stream ->
|
streamGroup.forEachIndexed { index, stream ->
|
||||||
MediaInfoSection(
|
MediaInfoSection(
|
||||||
title = audioLabel,
|
title =
|
||||||
|
titleIndex(
|
||||||
|
audioLabel,
|
||||||
|
groupIndex * 3 + index,
|
||||||
|
audioStreams.size,
|
||||||
|
),
|
||||||
items = buildAudioStreamInfo(context, stream),
|
items = buildAudioStreamInfo(context, stream),
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
|
|
@ -148,19 +163,21 @@ fun ItemDetailsDialog(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subtitle streams - display multiple per row
|
// Subtitle streams - display multiple per row
|
||||||
items(
|
val subtitleStreams = mediaStreams.filter { it.type == MediaStreamType.SUBTITLE }
|
||||||
mediaStreams
|
itemsIndexed(subtitleStreams.chunked(3)) { groupIndex, streamGroup ->
|
||||||
.filter { it.type == MediaStreamType.SUBTITLE }
|
|
||||||
.chunked(3),
|
|
||||||
) { streamGroup ->
|
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
streamGroup.forEach { stream ->
|
streamGroup.forEachIndexed { index, stream ->
|
||||||
MediaInfoSection(
|
MediaInfoSection(
|
||||||
title = subtitleLabel,
|
title =
|
||||||
items = buildSubtitleStreamInfo(context, stream),
|
titleIndex(
|
||||||
|
subtitleLabel,
|
||||||
|
groupIndex * 3 + index,
|
||||||
|
subtitleStreams.size,
|
||||||
|
),
|
||||||
|
items = buildSubtitleStreamInfo(context, stream, showFilePath),
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -185,6 +202,7 @@ private fun MediaInfoSection(
|
||||||
title: String,
|
title: String,
|
||||||
items: List<Pair<String, String>>,
|
items: List<Pair<String, String>>,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
additional: List<Pair<String, String>> = listOf(),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
|
@ -192,12 +210,17 @@ private fun MediaInfoSection(
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
|
modifier = Modifier.padding(start = 12.dp),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f, fill = false)) {
|
||||||
items.forEach { (label, value) ->
|
items.forEach { (label, value) ->
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(start = 12.dp),
|
modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "$label: ",
|
text = "$label: ",
|
||||||
|
|
@ -211,6 +234,37 @@ private fun MediaInfoSection(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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(
|
private fun buildVideoStreamInfo(
|
||||||
|
|
@ -218,40 +272,24 @@ private fun buildVideoStreamInfo(
|
||||||
stream: MediaStream,
|
stream: MediaStream,
|
||||||
): List<Pair<String, String>> =
|
): List<Pair<String, String>> =
|
||||||
buildList {
|
buildList {
|
||||||
val titleLabel = context.getString(R.string.title)
|
|
||||||
val codecLabel = context.getString(R.string.codec)
|
|
||||||
val avcLabel = context.getString(R.string.avc)
|
|
||||||
val profileLabel = context.getString(R.string.profile)
|
|
||||||
val levelLabel = context.getString(R.string.level)
|
|
||||||
val resolutionLabel = context.getString(R.string.resolution)
|
|
||||||
val aspectRatioLabel = context.getString(R.string.aspect_ratio)
|
|
||||||
val anamorphicLabel = context.getString(R.string.anamorphic)
|
|
||||||
val interlacedLabel = context.getString(R.string.interlaced)
|
|
||||||
val framerateLabel = context.getString(R.string.framerate)
|
|
||||||
val bitrateLabel = context.getString(R.string.bitrate)
|
|
||||||
val bitDepthLabel = context.getString(R.string.bit_depth)
|
|
||||||
val videoRangeLabel = context.getString(R.string.video_range)
|
|
||||||
val videoRangeTypeLabel = context.getString(R.string.video_range_type)
|
|
||||||
val colorSpaceLabel = context.getString(R.string.color_space)
|
|
||||||
val colorTransferLabel = context.getString(R.string.color_transfer)
|
|
||||||
val colorPrimariesLabel = context.getString(R.string.color_primaries)
|
|
||||||
val pixelFormatLabel = context.getString(R.string.pixel_format)
|
|
||||||
val refFramesLabel = context.getString(R.string.ref_frames)
|
|
||||||
val nalLabel = context.getString(R.string.nal)
|
|
||||||
val yesStr = context.getString(R.string.yes)
|
val yesStr = context.getString(R.string.yes)
|
||||||
val noStr = context.getString(R.string.no)
|
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 sdrStr = context.getString(R.string.sdr)
|
||||||
val hdrStr = context.getString(R.string.hdr)
|
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.title?.let { add(titleLabel to it) }
|
||||||
stream.codec?.let { add(codecLabel to it.uppercase()) }
|
stream.codec?.let { add(codecLabel to it.uppercase()) }
|
||||||
stream.isAvc?.let { add(avcLabel to if (it) yesStr else noStr) }
|
|
||||||
stream.profile?.let { add(profileLabel to it) }
|
|
||||||
stream.level?.let { add(levelLabel to it.toString()) }
|
|
||||||
if (stream.width != null && stream.height != null) {
|
if (stream.width != null && stream.height != null) {
|
||||||
add(resolutionLabel to "${stream.width}x${stream.height}")
|
add(resolutionLabel to "${stream.width}x${stream.height}")
|
||||||
}
|
}
|
||||||
|
|
@ -259,23 +297,55 @@ private fun buildVideoStreamInfo(
|
||||||
val aspectRatio = calculateAspectRatio(stream.width!!, stream.height!!)
|
val aspectRatio = calculateAspectRatio(stream.width!!, stream.height!!)
|
||||||
add(aspectRatioLabel to aspectRatio)
|
add(aspectRatioLabel to aspectRatio)
|
||||||
}
|
}
|
||||||
stream.isAnamorphic?.let { add(anamorphicLabel to if (it) yesStr else noStr) }
|
stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) }
|
||||||
stream.isInterlaced?.let { add(interlacedLabel to if (it) yesStr else noStr) }
|
|
||||||
stream.averageFrameRate?.let {
|
stream.averageFrameRate?.let {
|
||||||
add(framerateLabel to String.format(Locale.getDefault(), "%.3f", it))
|
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 =
|
val rangeStr =
|
||||||
when (it) {
|
when (it) {
|
||||||
VideoRange.SDR -> sdrStr
|
VideoRange.SDR -> sdrStr
|
||||||
VideoRange.HDR -> hdrStr
|
VideoRange.HDR -> hdrStr
|
||||||
VideoRange.UNKNOWN -> null
|
VideoRange.UNKNOWN -> null
|
||||||
else -> null
|
|
||||||
}
|
}
|
||||||
rangeStr?.let { add(videoRangeLabel to it) }
|
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 {
|
stream.videoRangeType?.let {
|
||||||
val rangeTypeStr =
|
val rangeTypeStr =
|
||||||
when (it) {
|
when (it) {
|
||||||
|
|
@ -304,7 +374,8 @@ private fun buildVideoStreamInfo(
|
||||||
stream.colorPrimaries?.let { add(colorPrimariesLabel to it) }
|
stream.colorPrimaries?.let { add(colorPrimariesLabel to it) }
|
||||||
stream.pixelFormat?.let { add(pixelFormatLabel to it) }
|
stream.pixelFormat?.let { add(pixelFormatLabel to it) }
|
||||||
stream.refFrames?.let { add(refFramesLabel to it.toString()) }
|
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(
|
private fun buildAudioStreamInfo(
|
||||||
|
|
@ -320,7 +391,7 @@ private fun buildAudioStreamInfo(
|
||||||
val bitrateLabel = context.getString(R.string.bitrate)
|
val bitrateLabel = context.getString(R.string.bitrate)
|
||||||
val sampleRateLabel = context.getString(R.string.sample_rate)
|
val sampleRateLabel = context.getString(R.string.sample_rate)
|
||||||
val defaultLabel = context.getString(R.string.default_track)
|
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 yesStr = context.getString(R.string.yes)
|
||||||
val noStr = context.getString(R.string.no)
|
val noStr = context.getString(R.string.no)
|
||||||
val sampleRateUnit = context.getString(R.string.sample_rate_unit)
|
val sampleRateUnit = context.getString(R.string.sample_rate_unit)
|
||||||
|
|
@ -328,11 +399,12 @@ private fun buildAudioStreamInfo(
|
||||||
stream.title?.let { add(titleLabel to it) }
|
stream.title?.let { add(titleLabel to it) }
|
||||||
stream.language?.let { add(languageLabel to languageName(it)) }
|
stream.language?.let { add(languageLabel to languageName(it)) }
|
||||||
stream.codec?.let {
|
stream.codec?.let {
|
||||||
val formattedCodec = formatAudioCodec(context, it, stream.profile) ?: it.uppercase()
|
val formattedCodec = formatAudioCodec(context, it, stream.profile) + " ($it)"
|
||||||
add(codecLabel to formattedCodec)
|
add(codecLabel to formattedCodec)
|
||||||
}
|
}
|
||||||
stream.channelLayout?.let { add(layoutLabel to it) }
|
stream.channelLayout?.let { add(layoutLabel to it) }
|
||||||
stream.channels?.let { add(channelsLabel to it.toString()) }
|
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.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) }
|
||||||
stream.sampleRate?.let { add(sampleRateLabel to "$it $sampleRateUnit") }
|
stream.sampleRate?.let { add(sampleRateLabel to "$it $sampleRateUnit") }
|
||||||
stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) }
|
stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) }
|
||||||
|
|
@ -341,28 +413,34 @@ private fun buildAudioStreamInfo(
|
||||||
private fun buildSubtitleStreamInfo(
|
private fun buildSubtitleStreamInfo(
|
||||||
context: Context,
|
context: Context,
|
||||||
stream: MediaStream,
|
stream: MediaStream,
|
||||||
|
showPath: Boolean,
|
||||||
): List<Pair<String, String>> =
|
): List<Pair<String, String>> =
|
||||||
buildList {
|
buildList {
|
||||||
val titleLabel = context.getString(R.string.title)
|
val titleLabel = context.getString(R.string.title)
|
||||||
val languageLabel = context.getString(R.string.language)
|
val languageLabel = context.getString(R.string.language)
|
||||||
val codecLabel = context.getString(R.string.codec)
|
val codecLabel = context.getString(R.string.codec)
|
||||||
val avcLabel = context.getString(R.string.avc)
|
|
||||||
val defaultLabel = context.getString(R.string.default_track)
|
val defaultLabel = context.getString(R.string.default_track)
|
||||||
val forcedLabel = context.getString(R.string.forced_track)
|
val forcedLabel = context.getString(R.string.forced_track)
|
||||||
val externalLabel = context.getString(R.string.external_track)
|
val externalLabel = context.getString(R.string.external_track)
|
||||||
val yesStr = context.getString(R.string.yes)
|
val yesStr = context.getString(R.string.yes)
|
||||||
val noStr = context.getString(R.string.no)
|
val noStr = context.getString(R.string.no)
|
||||||
|
val pathLabel = context.getString(R.string.path)
|
||||||
|
|
||||||
stream.title?.let { add(titleLabel to it) }
|
stream.title?.let { add(titleLabel to it) }
|
||||||
stream.language?.let { add(languageLabel to languageName(it)) }
|
stream.language?.let { add(languageLabel to languageName(it)) }
|
||||||
stream.codec?.let {
|
stream.codec?.let {
|
||||||
val formattedCodec = formatSubtitleCodec(it) ?: it.uppercase()
|
val formattedCodec = formatSubtitleCodec(it) + " ($it)"
|
||||||
add(codecLabel to formattedCodec)
|
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.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) }
|
||||||
stream.isForced?.let { add(forcedLabel 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.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(
|
private fun calculateAspectRatio(
|
||||||
|
|
|
||||||
|
|
@ -73,12 +73,11 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
private const val DEBUG = false
|
private const val DEBUG = false
|
||||||
|
|
||||||
interface CardGridItem {
|
interface CardGridItem {
|
||||||
val id: UUID
|
val gridId: String
|
||||||
val playable: Boolean
|
val playable: Boolean
|
||||||
val sortName: String
|
val sortName: String
|
||||||
}
|
}
|
||||||
|
|
@ -117,12 +116,16 @@ fun <T : CardGridItem> CardGrid(
|
||||||
val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0))
|
val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0))
|
||||||
|
|
||||||
val fractionCacheWindow = LazyLayoutCacheWindow(aheadFraction = 1f, behindFraction = 0.5f)
|
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 scope = rememberCoroutineScope()
|
||||||
val firstFocus = remember { FocusRequester() }
|
val firstFocus = remember { FocusRequester() }
|
||||||
val zeroFocus = remember { FocusRequester() }
|
val zeroFocus = remember { FocusRequester() }
|
||||||
var previouslyFocusedIndex by rememberSaveable { mutableIntStateOf(0) }
|
var previouslyFocusedIndex by rememberSaveable { mutableIntStateOf(0) }
|
||||||
var focusedIndex by rememberSaveable { mutableIntStateOf(initialPosition) }
|
|
||||||
|
|
||||||
var alphabetFocus by remember { mutableStateOf(false) }
|
var alphabetFocus by remember { mutableStateOf(false) }
|
||||||
val focusOn = { index: Int ->
|
val focusOn = { index: Int ->
|
||||||
|
|
@ -192,6 +195,19 @@ fun <T : CardGridItem> CardGrid(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
var longPressing by remember { mutableStateOf(false) }
|
var longPressing by remember { mutableStateOf(false) }
|
||||||
Row(
|
Row(
|
||||||
// horizontalArrangement = Arrangement.spacedBy(8.dp),
|
// horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
|
@ -227,7 +243,7 @@ fun <T : CardGridItem> CardGrid(
|
||||||
} else if (isPlayKeyUp(it)) {
|
} else if (isPlayKeyUp(it)) {
|
||||||
val item = pager.getOrNull(focusedIndex)
|
val item = pager.getOrNull(focusedIndex)
|
||||||
if (item?.playable == true) {
|
if (item?.playable == true) {
|
||||||
Timber.v("Clicked play on ${item.id}")
|
Timber.v("Clicked play on ${item.gridId}")
|
||||||
onClickPlay.invoke(focusedIndex, item)
|
onClickPlay.invoke(focusedIndex, item)
|
||||||
}
|
}
|
||||||
return@onKeyEvent true
|
return@onKeyEvent true
|
||||||
|
|
@ -358,7 +374,7 @@ fun <T : CardGridItem> CardGrid(
|
||||||
pager
|
pager
|
||||||
.getOrNull(focusedIndex)
|
.getOrNull(focusedIndex)
|
||||||
?.sortName
|
?.sortName
|
||||||
?.first()
|
?.firstOrNull()
|
||||||
?.uppercaseChar()
|
?.uppercaseChar()
|
||||||
?.let {
|
?.let {
|
||||||
if (it >= '0' && it <= '9') {
|
if (it >= '0' && it <= '9') {
|
||||||
|
|
@ -398,6 +414,7 @@ fun <T : CardGridItem> CardGrid(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
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.data.model.CollectionFolderFilter
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
|
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
|
||||||
|
|
@ -25,7 +24,6 @@ import java.util.UUID
|
||||||
fun CollectionFolderBoxSet(
|
fun CollectionFolderBoxSet(
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
itemId: UUID,
|
itemId: UUID,
|
||||||
item: BaseItem?,
|
|
||||||
recursive: Boolean,
|
recursive: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
filter: CollectionFolderFilter = CollectionFolderFilter(),
|
filter: CollectionFolderFilter = CollectionFolderFilter(),
|
||||||
|
|
|
||||||
|
|
@ -93,14 +93,19 @@ fun CollectionFolderLiveTv(
|
||||||
remember { viewModel.getRememberedTab(preferences, destination.itemId, 0) }
|
remember { viewModel.getRememberedTab(preferences, destination.itemId, 0) }
|
||||||
val folders by viewModel.recordingFolders.observeAsState(listOf())
|
val folders by viewModel.recordingFolders.observeAsState(listOf())
|
||||||
|
|
||||||
|
val tvGuideStr = stringResource(R.string.tv_guide)
|
||||||
|
val tvDvrStr = stringResource(R.string.tv_dvr_schedule)
|
||||||
val tabs =
|
val tabs =
|
||||||
|
remember(folders) {
|
||||||
listOf(
|
listOf(
|
||||||
TabId(stringResource(R.string.tv_guide), UUID.randomUUID()),
|
TabId(tvGuideStr, UUID.randomUUID()),
|
||||||
TabId(stringResource(R.string.tv_dvr_schedule), UUID.randomUUID()),
|
TabId(tvDvrStr, UUID.randomUUID()),
|
||||||
) + folders
|
) + folders
|
||||||
|
}
|
||||||
|
|
||||||
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
|
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val tabFocusRequesters = remember(tabs.size) { List(tabs.size) { FocusRequester() } }
|
||||||
|
|
||||||
val firstTabFocusRequester = remember { FocusRequester() }
|
val firstTabFocusRequester = remember { FocusRequester() }
|
||||||
LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
|
LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
|
||||||
|
|
@ -133,6 +138,7 @@ fun CollectionFolderLiveTv(
|
||||||
.focusRequester(firstTabFocusRequester),
|
.focusRequester(firstTabFocusRequester),
|
||||||
tabs = tabs.map { it.title },
|
tabs = tabs.map { it.title },
|
||||||
onClick = { selectedTabIndex = it },
|
onClick = { selectedTabIndex = it },
|
||||||
|
focusRequesters = tabFocusRequesters,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
when (selectedTabIndex) {
|
when (selectedTabIndex) {
|
||||||
|
|
@ -150,7 +156,9 @@ fun CollectionFolderLiveTv(
|
||||||
|
|
||||||
1 -> {
|
1 -> {
|
||||||
DvrSchedule(
|
DvrSchedule(
|
||||||
true,
|
requestFocusAfterLoading = true,
|
||||||
|
focusRequesterOnEmpty = tabFocusRequesters[1],
|
||||||
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.focusRequester(focusRequester),
|
.focusRequester(focusRequester),
|
||||||
|
|
@ -176,6 +184,7 @@ fun CollectionFolderLiveTv(
|
||||||
},
|
},
|
||||||
playEnabled = false,
|
playEnabled = false,
|
||||||
defaultViewOptions = ViewOptions(),
|
defaultViewOptions = ViewOptions(),
|
||||||
|
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
ErrorMessage("Invalid tab index $selectedTabIndex", null)
|
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.logTab
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
|
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -59,9 +58,10 @@ fun CollectionFolderMovie(
|
||||||
)
|
)
|
||||||
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
|
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } }
|
||||||
|
|
||||||
val firstTabFocusRequester = remember { FocusRequester() }
|
val firstTabFocusRequester = remember { FocusRequester() }
|
||||||
LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
|
// LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
|
||||||
|
|
||||||
LaunchedEffect(selectedTabIndex) {
|
LaunchedEffect(selectedTabIndex) {
|
||||||
logTab("movie", selectedTabIndex)
|
logTab("movie", selectedTabIndex)
|
||||||
|
|
@ -71,7 +71,6 @@ fun CollectionFolderMovie(
|
||||||
|
|
||||||
var showHeader by rememberSaveable { mutableStateOf(true) }
|
var showHeader by rememberSaveable { mutableStateOf(true) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
|
|
@ -88,6 +87,7 @@ fun CollectionFolderMovie(
|
||||||
.focusRequester(firstTabFocusRequester),
|
.focusRequester(firstTabFocusRequester),
|
||||||
tabs = tabs,
|
tabs = tabs,
|
||||||
onClick = { selectedTabIndex = it },
|
onClick = { selectedTabIndex = it },
|
||||||
|
focusRequesters = tabFocusRequesters,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
when (selectedTabIndex) {
|
when (selectedTabIndex) {
|
||||||
|
|
@ -115,6 +115,7 @@ fun CollectionFolderMovie(
|
||||||
preferencesViewModel.navigationManager.navigateTo(item.destination())
|
preferencesViewModel.navigationManager.navigateTo(item.destination())
|
||||||
},
|
},
|
||||||
itemId = destination.itemId,
|
itemId = destination.itemId,
|
||||||
|
viewModelKey = "${destination.itemId}_library",
|
||||||
initialFilter =
|
initialFilter =
|
||||||
CollectionFolderFilter(
|
CollectionFolderFilter(
|
||||||
filter =
|
filter =
|
||||||
|
|
@ -135,6 +136,7 @@ fun CollectionFolderMovie(
|
||||||
showHeader = position < columns
|
showHeader = position < columns
|
||||||
},
|
},
|
||||||
playEnabled = true,
|
playEnabled = true,
|
||||||
|
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -146,6 +148,7 @@ fun CollectionFolderMovie(
|
||||||
preferencesViewModel.navigationManager.navigateTo(item.destination())
|
preferencesViewModel.navigationManager.navigateTo(item.destination())
|
||||||
},
|
},
|
||||||
itemId = destination.itemId,
|
itemId = destination.itemId,
|
||||||
|
viewModelKey = "${destination.itemId}_collection",
|
||||||
initialFilter =
|
initialFilter =
|
||||||
CollectionFolderFilter(
|
CollectionFolderFilter(
|
||||||
filter =
|
filter =
|
||||||
|
|
@ -166,6 +169,7 @@ fun CollectionFolderMovie(
|
||||||
showHeader = position < columns
|
showHeader = position < columns
|
||||||
},
|
},
|
||||||
playEnabled = false,
|
playEnabled = false,
|
||||||
|
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -173,6 +177,7 @@ fun CollectionFolderMovie(
|
||||||
3 -> {
|
3 -> {
|
||||||
GenreCardGrid(
|
GenreCardGrid(
|
||||||
itemId = destination.itemId,
|
itemId = destination.itemId,
|
||||||
|
includeItemTypes = listOf(BaseItemKind.MOVIE),
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.padding(start = 16.dp)
|
.padding(start = 16.dp)
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
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.data.model.CollectionFolderFilter
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
|
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
|
||||||
|
|
@ -22,7 +21,6 @@ import java.util.UUID
|
||||||
fun CollectionFolderPlaylist(
|
fun CollectionFolderPlaylist(
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
itemId: UUID,
|
itemId: UUID,
|
||||||
item: BaseItem?,
|
|
||||||
recursive: Boolean,
|
recursive: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
filter: CollectionFolderFilter = CollectionFolderFilter(),
|
filter: CollectionFolderFilter = CollectionFolderFilter(),
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ fun CollectionFolderTv(
|
||||||
)
|
)
|
||||||
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
|
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } }
|
||||||
|
|
||||||
val firstTabFocusRequester = remember { FocusRequester() }
|
val firstTabFocusRequester = remember { FocusRequester() }
|
||||||
LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
|
LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
|
||||||
|
|
@ -74,7 +75,6 @@ fun CollectionFolderTv(
|
||||||
|
|
||||||
var showHeader by rememberSaveable { mutableStateOf(true) }
|
var showHeader by rememberSaveable { mutableStateOf(true) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
|
|
@ -91,6 +91,7 @@ fun CollectionFolderTv(
|
||||||
.focusRequester(firstTabFocusRequester),
|
.focusRequester(firstTabFocusRequester),
|
||||||
tabs = tabs,
|
tabs = tabs,
|
||||||
onClick = { selectedTabIndex = it },
|
onClick = { selectedTabIndex = it },
|
||||||
|
focusRequesters = tabFocusRequesters,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
when (selectedTabIndex) {
|
when (selectedTabIndex) {
|
||||||
|
|
@ -138,6 +139,7 @@ fun CollectionFolderTv(
|
||||||
preferencesViewModel.navigationManager.navigateTo(item.destination())
|
preferencesViewModel.navigationManager.navigateTo(item.destination())
|
||||||
},
|
},
|
||||||
playEnabled = false,
|
playEnabled = false,
|
||||||
|
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,6 +147,7 @@ fun CollectionFolderTv(
|
||||||
2 -> {
|
2 -> {
|
||||||
GenreCardGrid(
|
GenreCardGrid(
|
||||||
itemId = destination.itemId,
|
itemId = destination.itemId,
|
||||||
|
includeItemTypes = listOf(BaseItemKind.SERIES),
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.padding(start = 16.dp)
|
.padding(start = 16.dp)
|
||||||
|
|
|
||||||
|
|
@ -254,7 +254,7 @@ fun DebugPage(
|
||||||
"Manufacturer: ${Build.MANUFACTURER}",
|
"Manufacturer: ${Build.MANUFACTURER}",
|
||||||
"Model: ${Build.MODEL}",
|
"Model: ${Build.MODEL}",
|
||||||
"Display Modes:",
|
"Display Modes:",
|
||||||
*viewModel.refreshRateService.displayModes,
|
*viewModel.refreshRateService.supportedDisplayModes,
|
||||||
).forEach {
|
).forEach {
|
||||||
Text(
|
Text(
|
||||||
text = it.toString(),
|
text = it.toString(),
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import android.content.Context
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||||
import androidx.compose.material.icons.filled.ArrowForward
|
import androidx.compose.material.icons.filled.ArrowForward
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Info
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
|
@ -29,6 +30,12 @@ data class MoreDialogActions(
|
||||||
var onClickAddPlaylist: (UUID) -> Unit,
|
var onClickAddPlaylist: (UUID) -> Unit,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
enum class ClearChosenStreams {
|
||||||
|
NONE,
|
||||||
|
ITEM_AND_SERIES,
|
||||||
|
SERIES,
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the [DialogItem]s when clicking "More"
|
* Build the [DialogItem]s when clicking "More"
|
||||||
*
|
*
|
||||||
|
|
@ -53,10 +60,12 @@ fun buildMoreDialogItems(
|
||||||
sourceId: UUID?,
|
sourceId: UUID?,
|
||||||
watched: Boolean,
|
watched: Boolean,
|
||||||
favorite: Boolean,
|
favorite: Boolean,
|
||||||
|
canClearChosenStreams: Boolean,
|
||||||
actions: MoreDialogActions,
|
actions: MoreDialogActions,
|
||||||
onChooseVersion: () -> Unit,
|
onChooseVersion: () -> Unit,
|
||||||
onChooseTracks: (MediaStreamType) -> Unit,
|
onChooseTracks: (MediaStreamType) -> Unit,
|
||||||
onShowOverview: () -> Unit,
|
onShowOverview: () -> Unit,
|
||||||
|
onClearChosenStreams: () -> Unit,
|
||||||
): List<DialogItem> =
|
): List<DialogItem> =
|
||||||
buildList {
|
buildList {
|
||||||
add(
|
add(
|
||||||
|
|
@ -69,7 +78,6 @@ fun buildMoreDialogItems(
|
||||||
Destination.Playback(
|
Destination.Playback(
|
||||||
item.id,
|
item.id,
|
||||||
item.resumeMs ?: 0L,
|
item.resumeMs ?: 0L,
|
||||||
item,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -157,6 +165,7 @@ fun buildMoreDialogItems(
|
||||||
Destination.MediaItem(
|
Destination.MediaItem(
|
||||||
seriesId,
|
seriesId,
|
||||||
BaseItemKind.SERIES,
|
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(
|
add(
|
||||||
DialogItem(
|
DialogItem(
|
||||||
context.getString(R.string.play_with_transcoding),
|
context.getString(R.string.play_with_transcoding),
|
||||||
|
|
@ -181,7 +200,6 @@ fun buildMoreDialogItems(
|
||||||
Destination.Playback(
|
Destination.Playback(
|
||||||
item.id,
|
item.id,
|
||||||
item.resumeMs ?: 0L,
|
item.resumeMs ?: 0L,
|
||||||
item,
|
|
||||||
forceTranscoding = true,
|
forceTranscoding = true,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -290,6 +308,7 @@ fun buildMoreDialogItemsForHome(
|
||||||
Destination.MediaItem(
|
Destination.MediaItem(
|
||||||
it,
|
it,
|
||||||
BaseItemKind.SERIES,
|
BaseItemKind.SERIES,
|
||||||
|
null,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -309,7 +328,7 @@ fun buildMoreDialogItemsForPerson(
|
||||||
context.getString(R.string.go_to),
|
context.getString(R.string.go_to),
|
||||||
Icons.Default.ArrowForward,
|
Icons.Default.ArrowForward,
|
||||||
) {
|
) {
|
||||||
actions.navigateTo(Destination.MediaItem(itemId, BaseItemKind.PERSON))
|
actions.navigateTo(Destination.MediaItem(itemId, BaseItemKind.PERSON, null))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
add(
|
add(
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ fun FavoritesPage(
|
||||||
stringResource(R.string.playlists),
|
stringResource(R.string.playlists),
|
||||||
stringResource(R.string.people),
|
stringResource(R.string.people),
|
||||||
)
|
)
|
||||||
|
val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } }
|
||||||
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
|
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
|
@ -107,6 +108,7 @@ fun FavoritesPage(
|
||||||
.padding(start = 32.dp, top = 16.dp, bottom = 16.dp),
|
.padding(start = 32.dp, top = 16.dp, bottom = 16.dp),
|
||||||
tabs = tabs,
|
tabs = tabs,
|
||||||
onClick = { selectedTabIndex = it },
|
onClick = { selectedTabIndex = it },
|
||||||
|
focusRequesters = tabFocusRequesters,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// TODO playEnabled = true for movies & episodes
|
// TODO playEnabled = true for movies & episodes
|
||||||
|
|
@ -139,6 +141,7 @@ fun FavoritesPage(
|
||||||
},
|
},
|
||||||
playEnabled = false,
|
playEnabled = false,
|
||||||
filterOptions = DefaultForFavoritesFilterOptions,
|
filterOptions = DefaultForFavoritesFilterOptions,
|
||||||
|
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -170,6 +173,7 @@ fun FavoritesPage(
|
||||||
},
|
},
|
||||||
playEnabled = false,
|
playEnabled = false,
|
||||||
filterOptions = DefaultForFavoritesFilterOptions,
|
filterOptions = DefaultForFavoritesFilterOptions,
|
||||||
|
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -202,6 +206,7 @@ fun FavoritesPage(
|
||||||
},
|
},
|
||||||
playEnabled = false,
|
playEnabled = false,
|
||||||
filterOptions = DefaultForFavoritesFilterOptions,
|
filterOptions = DefaultForFavoritesFilterOptions,
|
||||||
|
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -233,6 +238,7 @@ fun FavoritesPage(
|
||||||
},
|
},
|
||||||
playEnabled = false,
|
playEnabled = false,
|
||||||
filterOptions = DefaultForFavoritesFilterOptions,
|
filterOptions = DefaultForFavoritesFilterOptions,
|
||||||
|
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -264,6 +270,7 @@ fun FavoritesPage(
|
||||||
},
|
},
|
||||||
playEnabled = false,
|
playEnabled = false,
|
||||||
filterOptions = DefaultForFavoritesFilterOptions,
|
filterOptions = DefaultForFavoritesFilterOptions,
|
||||||
|
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -300,6 +307,7 @@ fun FavoritesPage(
|
||||||
},
|
},
|
||||||
playEnabled = false,
|
playEnabled = false,
|
||||||
filterOptions = listOf(),
|
filterOptions = listOf(),
|
||||||
|
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ abstract class ItemViewModel(
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val item = MutableLiveData<BaseItem?>(null)
|
val item = MutableLiveData<BaseItem?>(null)
|
||||||
lateinit var itemId: String
|
lateinit var itemId: String
|
||||||
lateinit var itemUuid: UUID
|
var itemUuid: UUID? = null
|
||||||
|
|
||||||
suspend fun fetchItem(itemId: UUID): BaseItem =
|
suspend fun fetchItem(itemId: UUID): BaseItem =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
|
@ -40,13 +41,17 @@ import androidx.tv.material3.surfaceColorAtElevation
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.services.SeerrService
|
||||||
|
import com.github.damontecres.wholphin.ui.Cards
|
||||||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||||
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
||||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
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.ErrorMessage
|
||||||
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
|
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
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.components.OverviewText
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
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.formatDate
|
||||||
|
import com.github.damontecres.wholphin.ui.ifElse
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
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.theme.WholphinTheme
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||||
|
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import com.github.damontecres.wholphin.util.RowLoadingState
|
import com.github.damontecres.wholphin.util.RowLoadingState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
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.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
|
@ -86,10 +98,12 @@ class PersonViewModel
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
private val favoriteWatchManager: FavoriteWatchManager,
|
private val favoriteWatchManager: FavoriteWatchManager,
|
||||||
|
private val seerrService: SeerrService,
|
||||||
) : LoadingItemViewModel(api) {
|
) : LoadingItemViewModel(api) {
|
||||||
val movies = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
|
val movies = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
|
||||||
val series = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
|
val series = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
|
||||||
val episodes = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
|
val episodes = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
|
||||||
|
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
|
||||||
|
|
||||||
fun init(itemId: UUID) {
|
fun init(itemId: UUID) {
|
||||||
viewModelScope.launchIO(
|
viewModelScope.launchIO(
|
||||||
|
|
@ -115,6 +129,10 @@ class PersonViewModel
|
||||||
} else {
|
} else {
|
||||||
episodes.setValueOnMain(RowLoadingState.Success(listOf()))
|
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) {
|
fun setFavorite(favorite: Boolean) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
favoriteWatchManager.setFavorite(itemUuid, favorite)
|
itemUuid?.let {
|
||||||
fetchAndSetItem(itemUuid)
|
favoriteWatchManager.setFavorite(it, favorite)
|
||||||
|
fetchAndSetItem(it)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -175,6 +195,7 @@ fun PersonPage(
|
||||||
val movies by viewModel.movies.observeAsState(RowLoadingState.Pending)
|
val movies by viewModel.movies.observeAsState(RowLoadingState.Pending)
|
||||||
val series by viewModel.series.observeAsState(RowLoadingState.Pending)
|
val series by viewModel.series.observeAsState(RowLoadingState.Pending)
|
||||||
val episodes by viewModel.episodes.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)
|
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||||
when (val state = loading) {
|
when (val state = loading) {
|
||||||
|
|
@ -213,6 +234,10 @@ fun PersonPage(
|
||||||
favoriteOnClick = {
|
favoriteOnClick = {
|
||||||
viewModel.setFavorite(!person.favorite)
|
viewModel.setFavorite(!person.favorite)
|
||||||
},
|
},
|
||||||
|
discovered = discovered,
|
||||||
|
onClickDiscover = { index, item ->
|
||||||
|
viewModel.navigationManager.navigateTo(item.destination)
|
||||||
|
},
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
AnimatedVisibility(showOverviewDialog) {
|
AnimatedVisibility(showOverviewDialog) {
|
||||||
|
|
@ -237,6 +262,7 @@ private const val HEADER_ROW = 0
|
||||||
private const val MOVIE_ROW = 1
|
private const val MOVIE_ROW = 1
|
||||||
private const val SERIES_ROW = 2
|
private const val SERIES_ROW = 2
|
||||||
private const val EPISODE_ROW = 3
|
private const val EPISODE_ROW = 3
|
||||||
|
private const val DISCOVER_ROW = EPISODE_ROW + 1
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PersonPageContent(
|
fun PersonPageContent(
|
||||||
|
|
@ -251,9 +277,11 @@ fun PersonPageContent(
|
||||||
movies: RowLoadingState,
|
movies: RowLoadingState,
|
||||||
series: RowLoadingState,
|
series: RowLoadingState,
|
||||||
episodes: RowLoadingState,
|
episodes: RowLoadingState,
|
||||||
|
discovered: List<DiscoverItem>,
|
||||||
onClickItem: (Int, BaseItem) -> Unit,
|
onClickItem: (Int, BaseItem) -> Unit,
|
||||||
overviewOnClick: () -> Unit,
|
overviewOnClick: () -> Unit,
|
||||||
favoriteOnClick: () -> Unit,
|
favoriteOnClick: () -> Unit,
|
||||||
|
onClickDiscover: (Int, DiscoverItem) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
@ -332,9 +360,44 @@ fun PersonPageContent(
|
||||||
onClickItem = onClickItem,
|
onClickItem = onClickItem,
|
||||||
onClickPosition = { position = it },
|
onClickPosition = { position = it },
|
||||||
showIfEmpty = false,
|
showIfEmpty = false,
|
||||||
horizontalPadding = 32.dp,
|
horizontalPadding = 24.dp,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
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 = {
|
trailingContent = {
|
||||||
item?.data?.runTimeTicks?.ticks?.roundMinutes?.let { duration ->
|
item?.data?.runTimeTicks?.ticks?.roundMinutes?.let { duration ->
|
||||||
val now = LocalClock.current.now
|
val now by LocalClock.current.now
|
||||||
val endTimeStr =
|
val endTimeStr =
|
||||||
remember(item, now) {
|
remember(item, now) {
|
||||||
val endTime = now.toLocalTime().plusSeconds(duration.inWholeSeconds)
|
val endTime = now.toLocalTime().plusSeconds(duration.inWholeSeconds)
|
||||||
|
|
@ -444,6 +444,7 @@ fun PlaylistItem(
|
||||||
watched = item?.data?.userData?.played ?: false,
|
watched = item?.data?.userData?.played ?: false,
|
||||||
unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1,
|
unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1,
|
||||||
watchedPercent = 0.0,
|
watchedPercent = 0.0,
|
||||||
|
numberOfVersions = item?.data?.mediaSourceCount ?: 0,
|
||||||
modifier = Modifier.width(160.dp),
|
modifier = Modifier.width(160.dp),
|
||||||
useFallbackText = false,
|
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.foundation.relocation.bringIntoViewRequester
|
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
|
@ -26,11 +25,11 @@ import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
import androidx.lifecycle.compose.LifecycleStartEffect
|
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.ChosenStreams
|
import com.github.damontecres.wholphin.data.ChosenStreams
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
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.DialogParams
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
|
|
@ -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.detail.buildMoreDialogItems
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.rememberInt
|
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.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
import org.jellyfin.sdk.model.api.MediaType
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import org.jellyfin.sdk.model.serializer.toUUID
|
import org.jellyfin.sdk.model.serializer.toUUID
|
||||||
|
|
@ -113,12 +112,12 @@ fun EpisodeDetails(
|
||||||
|
|
||||||
LoadingState.Success -> {
|
LoadingState.Success -> {
|
||||||
item?.let { ep ->
|
item?.let { ep ->
|
||||||
LifecycleStartEffect(destination.itemId) {
|
LifecycleResumeEffect(destination.itemId) {
|
||||||
viewModel.maybePlayThemeSong(
|
viewModel.maybePlayThemeSong(
|
||||||
destination.itemId,
|
destination.itemId,
|
||||||
preferences.appPreferences.interfacePreferences.playThemeSongs,
|
preferences.appPreferences.interfacePreferences.playThemeSongs,
|
||||||
)
|
)
|
||||||
onStopOrDispose {
|
onPauseOrDispose {
|
||||||
viewModel.release()
|
viewModel.release()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -131,7 +130,6 @@ fun EpisodeDetails(
|
||||||
Destination.Playback(
|
Destination.Playback(
|
||||||
ep.id,
|
ep.id,
|
||||||
it.inWholeMilliseconds,
|
it.inWholeMilliseconds,
|
||||||
ep,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -157,6 +155,7 @@ fun EpisodeDetails(
|
||||||
favorite = ep.data.userData?.isFavorite ?: false,
|
favorite = ep.data.userData?.isFavorite ?: false,
|
||||||
seriesId = ep.data.seriesId,
|
seriesId = ep.data.seriesId,
|
||||||
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
|
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
|
||||||
|
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
|
||||||
actions = moreActions,
|
actions = moreActions,
|
||||||
onChooseVersion = {
|
onChooseVersion = {
|
||||||
chooseVersion =
|
chooseVersion =
|
||||||
|
|
@ -182,6 +181,12 @@ fun EpisodeDetails(
|
||||||
chooseStream(
|
chooseStream(
|
||||||
context = context,
|
context = context,
|
||||||
streams = source.mediaStreams.orEmpty(),
|
streams = source.mediaStreams.orEmpty(),
|
||||||
|
currentIndex =
|
||||||
|
if (type == MediaStreamType.AUDIO) {
|
||||||
|
chosenStreams?.audioStream?.index
|
||||||
|
} else {
|
||||||
|
chosenStreams?.subtitleStream?.index
|
||||||
|
},
|
||||||
type = type,
|
type = type,
|
||||||
onClick = { trackIndex ->
|
onClick = { trackIndex ->
|
||||||
viewModel.saveTrackSelection(
|
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 resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO
|
||||||
|
|
||||||
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||||
LaunchedEffect(Unit) {
|
RequestOrRestoreFocus(focusRequesters.getOrNull(position))
|
||||||
focusRequesters.getOrNull(position)?.tryRequestFocus()
|
|
||||||
}
|
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
|
@ -338,6 +344,8 @@ fun EpisodeDetailsContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
trailers = null,
|
||||||
|
trailerOnClick = {},
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.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.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.components.EpisodeName
|
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.OverviewText
|
||||||
|
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
||||||
import com.github.damontecres.wholphin.ui.components.SeriesName
|
import com.github.damontecres.wholphin.ui.components.SeriesName
|
||||||
import com.github.damontecres.wholphin.ui.components.VideoStreamDetails
|
import com.github.damontecres.wholphin.ui.components.VideoStreamDetails
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
|
|
@ -55,10 +55,11 @@ fun EpisodeDetailsHeader(
|
||||||
modifier = Modifier.fillMaxWidth(.60f),
|
modifier = Modifier.fillMaxWidth(.60f),
|
||||||
) {
|
) {
|
||||||
val padding = 8.dp
|
val padding = 8.dp
|
||||||
EpisodeQuickDetails(dto)
|
QuickDetails(ep.ui.quickDetails, ep.timeRemainingOrRuntime)
|
||||||
|
|
||||||
VideoStreamDetails(
|
VideoStreamDetails(
|
||||||
chosenStreams = chosenStreams,
|
chosenStreams = chosenStreams,
|
||||||
|
numberOfVersions = dto.mediaSourceCount ?: 0,
|
||||||
modifier = Modifier.padding(bottom = padding),
|
modifier = Modifier.padding(bottom = padding),
|
||||||
)
|
)
|
||||||
dto.taglines?.firstOrNull()?.let { tagline ->
|
dto.taglines?.firstOrNull()?.let { tagline ->
|
||||||
|
|
|
||||||
|
|
@ -182,4 +182,19 @@ class EpisodeViewModel
|
||||||
release()
|
release()
|
||||||
navigationManager.navigateTo(destination)
|
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
|
@Composable
|
||||||
fun DvrSchedule(
|
fun DvrSchedule(
|
||||||
requestFocusAfterLoading: Boolean,
|
requestFocusAfterLoading: Boolean,
|
||||||
|
focusRequesterOnEmpty: FocusRequester,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: DvrScheduleViewModel = hiltViewModel(),
|
viewModel: DvrScheduleViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
|
|
@ -138,7 +139,13 @@ fun DvrSchedule(
|
||||||
var showDialog by remember { mutableStateOf<BaseItem?>(null) }
|
var showDialog by remember { mutableStateOf<BaseItem?>(null) }
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
if (requestFocusAfterLoading) {
|
if (requestFocusAfterLoading) {
|
||||||
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
LaunchedEffect(Unit) {
|
||||||
|
if (active.isNotEmpty() || recordings.isNotEmpty()) {
|
||||||
|
focusRequester.tryRequestFocus()
|
||||||
|
} else {
|
||||||
|
focusRequesterOnEmpty.tryRequestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
DvrScheduleContent(
|
DvrScheduleContent(
|
||||||
activeRecordings = active,
|
activeRecordings = active,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
package com.github.damontecres.wholphin.ui.detail.livetv
|
package com.github.damontecres.wholphin.ui.detail.livetv
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.ViewModel
|
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.AppColors
|
||||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode
|
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.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
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.setValueOnMain
|
||||||
import com.github.damontecres.wholphin.ui.toServerString
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
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 org.jellyfin.sdk.model.extensions.ticks
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
import java.time.temporal.ChronoUnit
|
import java.time.temporal.ChronoUnit
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -58,6 +64,7 @@ import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.Duration.Companion.minutes
|
import kotlin.time.Duration.Companion.minutes
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
import kotlin.time.toKotlinDuration
|
||||||
|
|
||||||
const val MAX_HOURS = 48L
|
const val MAX_HOURS = 48L
|
||||||
|
|
||||||
|
|
@ -528,6 +535,7 @@ data class TvChannel(
|
||||||
val imageUrl: String?,
|
val imageUrl: String?,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Stable
|
||||||
data class TvProgram(
|
data class TvProgram(
|
||||||
val id: UUID,
|
val id: UUID,
|
||||||
val channelId: UUID,
|
val channelId: UUID,
|
||||||
|
|
@ -548,6 +556,49 @@ data class TvProgram(
|
||||||
) {
|
) {
|
||||||
val isFake = category == ProgramCategory.FAKE
|
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 {
|
companion object {
|
||||||
private val NO_DATA = WholphinApplication.instance.getString(R.string.no_data)
|
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.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
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.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.rememberPosition
|
import com.github.damontecres.wholphin.ui.rememberPosition
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import eu.wewox.programguide.ProgramGuide
|
import eu.wewox.programguide.ProgramGuide
|
||||||
import eu.wewox.programguide.ProgramGuideDimensions
|
import eu.wewox.programguide.ProgramGuideDimensions
|
||||||
|
|
@ -289,22 +289,8 @@ fun TvGuideGridContent(
|
||||||
|
|
||||||
var focusedItem by rememberPosition(RowColumn(0, 0))
|
var focusedItem by rememberPosition(RowColumn(0, 0))
|
||||||
val focusedChannelIndex = focusedItem.row
|
val focusedChannelIndex = focusedItem.row
|
||||||
val focusedProgramIndex =
|
var focusedProgramIndex by remember { mutableIntStateOf(0) }
|
||||||
remember(programs.range, focusedItem) {
|
|
||||||
focusedItem.let { focus ->
|
|
||||||
(programs.range.first..<focus.row).sumOf {
|
|
||||||
val channelId = channels[it].id
|
|
||||||
channelProgramCount[channelId] ?: 0
|
|
||||||
} + focus.column
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(focusedProgramIndex) {
|
|
||||||
// Timber.v("Focusing on $focusedItem, programIndex=$focusedProgramIndex")
|
|
||||||
scope.launch(ExceptionHandler()) {
|
|
||||||
state.animateToProgram(focusedProgramIndex, Alignment.Center)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var gridHasFocus by rememberSaveable { mutableStateOf(false) }
|
var gridHasFocus by rememberSaveable { mutableStateOf(false) }
|
||||||
var channelColumnFocused by rememberSaveable { mutableStateOf(false) }
|
var channelColumnFocused by rememberSaveable { mutableStateOf(false) }
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
|
|
@ -485,7 +471,7 @@ fun TvGuideGridContent(
|
||||||
|
|
||||||
if (newFocusedItem != null) {
|
if (newFocusedItem != null) {
|
||||||
val channel = channels[newFocusedItem.row]
|
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
|
// Ensure it isn't going out of range
|
||||||
val toFocus =
|
val toFocus =
|
||||||
newFocusedItem
|
newFocusedItem
|
||||||
|
|
@ -494,10 +480,24 @@ fun TvGuideGridContent(
|
||||||
column =
|
column =
|
||||||
newFocusedItem.column.coerceIn(
|
newFocusedItem.column.coerceIn(
|
||||||
0,
|
0,
|
||||||
(programs.size - 1).coerceAtLeast(0),
|
(channelPrograms.size - 1).coerceAtLeast(0),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
focusedItem = toFocus
|
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)
|
onFocus(toFocus)
|
||||||
return@onPreviewKeyEvent true
|
return@onPreviewKeyEvent true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
package com.github.damontecres.wholphin.ui.detail.livetv
|
package com.github.damontecres.wholphin.ui.detail.livetv
|
||||||
|
|
||||||
import android.text.format.DateUtils
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
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.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.ui.components.DotSeparatedRow
|
|
||||||
import com.github.damontecres.wholphin.ui.components.OverviewText
|
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.components.StreamLabel
|
||||||
import com.github.damontecres.wholphin.ui.roundMinutes
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
import java.time.ZoneId
|
|
||||||
import kotlin.time.toKotlinDuration
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TvGuideHeader(
|
fun TvGuideHeader(
|
||||||
program: TvProgram?,
|
program: TvProgram?,
|
||||||
modifier: Modifier = Modifier,
|
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(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
horizontalAlignment = Alignment.Start,
|
horizontalAlignment = Alignment.Start,
|
||||||
|
|
@ -100,13 +53,7 @@ fun TvGuideHeader(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
DotSeparatedRow(
|
program?.quickDetails?.let { QuickDetails(it, null) }
|
||||||
texts = details,
|
|
||||||
communityRating = null,
|
|
||||||
criticRating = null,
|
|
||||||
textStyle = MaterialTheme.typography.titleSmall,
|
|
||||||
modifier = Modifier,
|
|
||||||
)
|
|
||||||
if (program?.isRepeat == true) {
|
if (program?.isRepeat == true) {
|
||||||
StreamLabel(stringResource(R.string.live_tv_repeat))
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.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.foundation.relocation.bringIntoViewRequester
|
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
|
@ -24,31 +21,26 @@ import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.focusRestorer
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
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.R
|
||||||
import com.github.damontecres.wholphin.data.ChosenStreams
|
import com.github.damontecres.wholphin.data.ChosenStreams
|
||||||
import com.github.damontecres.wholphin.data.ExtrasItem
|
import com.github.damontecres.wholphin.data.ExtrasItem
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.Chapter
|
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.Person
|
||||||
import com.github.damontecres.wholphin.data.model.RemoteTrailer
|
|
||||||
import com.github.damontecres.wholphin.data.model.Trailer
|
import com.github.damontecres.wholphin.data.model.Trailer
|
||||||
import com.github.damontecres.wholphin.data.model.aspectRatioFloat
|
import com.github.damontecres.wholphin.data.model.aspectRatioFloat
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.TrailerService
|
import com.github.damontecres.wholphin.services.TrailerService
|
||||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||||
import com.github.damontecres.wholphin.ui.Cards
|
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.ChapterRow
|
||||||
import com.github.damontecres.wholphin.ui.cards.ExtrasRow
|
import com.github.damontecres.wholphin.ui.cards.ExtrasRow
|
||||||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
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.buildMoreDialogItems
|
||||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
||||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson
|
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.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.rememberInt
|
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.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
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.api.MediaType
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import org.jellyfin.sdk.model.serializer.toUUID
|
import org.jellyfin.sdk.model.serializer.toUUID
|
||||||
|
|
@ -109,6 +104,7 @@ fun MovieDetails(
|
||||||
val similar by viewModel.similar.observeAsState(listOf())
|
val similar by viewModel.similar.observeAsState(listOf())
|
||||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||||
val chosenStreams by viewModel.chosenStreams.observeAsState(null)
|
val chosenStreams by viewModel.chosenStreams.observeAsState(null)
|
||||||
|
val discovered by viewModel.discovered.collectAsState()
|
||||||
|
|
||||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||||
|
|
@ -144,12 +140,12 @@ fun MovieDetails(
|
||||||
|
|
||||||
LoadingState.Success -> {
|
LoadingState.Success -> {
|
||||||
item?.let { movie ->
|
item?.let { movie ->
|
||||||
LifecycleStartEffect(destination.itemId) {
|
LifecycleResumeEffect(destination.itemId) {
|
||||||
viewModel.maybePlayThemeSong(
|
viewModel.maybePlayThemeSong(
|
||||||
destination.itemId,
|
destination.itemId,
|
||||||
preferences.appPreferences.interfacePreferences.playThemeSongs,
|
preferences.appPreferences.interfacePreferences.playThemeSongs,
|
||||||
)
|
)
|
||||||
onStopOrDispose {
|
onPauseOrDispose {
|
||||||
viewModel.release()
|
viewModel.release()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -178,7 +174,6 @@ fun MovieDetails(
|
||||||
Destination.Playback(
|
Destination.Playback(
|
||||||
movie.id,
|
movie.id,
|
||||||
it.inWholeMilliseconds,
|
it.inWholeMilliseconds,
|
||||||
movie,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -204,6 +199,7 @@ fun MovieDetails(
|
||||||
favorite = movie.data.userData?.isFavorite ?: false,
|
favorite = movie.data.userData?.isFavorite ?: false,
|
||||||
seriesId = null,
|
seriesId = null,
|
||||||
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
|
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
|
||||||
|
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
|
||||||
actions = moreActions,
|
actions = moreActions,
|
||||||
onChooseVersion = {
|
onChooseVersion = {
|
||||||
chooseVersion =
|
chooseVersion =
|
||||||
|
|
@ -220,6 +216,7 @@ fun MovieDetails(
|
||||||
moreDialog = null
|
moreDialog = null
|
||||||
},
|
},
|
||||||
onChooseTracks = { type ->
|
onChooseTracks = { type ->
|
||||||
|
|
||||||
viewModel.streamChoiceService
|
viewModel.streamChoiceService
|
||||||
.chooseSource(
|
.chooseSource(
|
||||||
movie.data,
|
movie.data,
|
||||||
|
|
@ -230,6 +227,12 @@ fun MovieDetails(
|
||||||
context = context,
|
context = context,
|
||||||
streams = source.mediaStreams.orEmpty(),
|
streams = source.mediaStreams.orEmpty(),
|
||||||
type = type,
|
type = type,
|
||||||
|
currentIndex =
|
||||||
|
if (type == MediaStreamType.AUDIO) {
|
||||||
|
chosenStreams?.audioStream?.index
|
||||||
|
} else {
|
||||||
|
chosenStreams?.subtitleStream?.index
|
||||||
|
},
|
||||||
onClick = { trackIndex ->
|
onClick = { trackIndex ->
|
||||||
viewModel.saveTrackSelection(
|
viewModel.saveTrackSelection(
|
||||||
movie,
|
movie,
|
||||||
|
|
@ -252,6 +255,9 @@ fun MovieDetails(
|
||||||
files = movie.data.mediaSources.orEmpty(),
|
files = movie.data.mediaSources.orEmpty(),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
onClearChosenStreams = {
|
||||||
|
viewModel.clearChosenStreams(chosenStreams)
|
||||||
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -299,6 +305,10 @@ fun MovieDetails(
|
||||||
onClickExtra = { index, extra ->
|
onClickExtra = { index, extra ->
|
||||||
viewModel.navigateTo(extra.destination)
|
viewModel.navigateTo(extra.destination)
|
||||||
},
|
},
|
||||||
|
discovered = discovered,
|
||||||
|
onClickDiscover = { index, item ->
|
||||||
|
viewModel.navigateTo(item.destination)
|
||||||
|
},
|
||||||
modifier = modifier,
|
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 CHAPTER_ROW = TRAILER_ROW + 1
|
||||||
private const val EXTRAS_ROW = CHAPTER_ROW + 1
|
private const val EXTRAS_ROW = CHAPTER_ROW + 1
|
||||||
private const val SIMILAR_ROW = EXTRAS_ROW + 1
|
private const val SIMILAR_ROW = EXTRAS_ROW + 1
|
||||||
|
private const val DISCOVER_ROW = SIMILAR_ROW + 1
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MovieDetailsContent(
|
fun MovieDetailsContent(
|
||||||
|
|
@ -370,6 +381,7 @@ fun MovieDetailsContent(
|
||||||
trailers: List<Trailer>,
|
trailers: List<Trailer>,
|
||||||
extras: List<ExtrasItem>,
|
extras: List<ExtrasItem>,
|
||||||
similar: List<BaseItem>,
|
similar: List<BaseItem>,
|
||||||
|
discovered: List<DiscoverItem>,
|
||||||
playOnClick: (Duration) -> Unit,
|
playOnClick: (Duration) -> Unit,
|
||||||
trailerOnClick: (Trailer) -> Unit,
|
trailerOnClick: (Trailer) -> Unit,
|
||||||
overviewOnClick: () -> Unit,
|
overviewOnClick: () -> Unit,
|
||||||
|
|
@ -381,19 +393,20 @@ fun MovieDetailsContent(
|
||||||
onLongClickPerson: (Int, Person) -> Unit,
|
onLongClickPerson: (Int, Person) -> Unit,
|
||||||
onLongClickSimilar: (Int, BaseItem) -> Unit,
|
onLongClickSimilar: (Int, BaseItem) -> Unit,
|
||||||
onClickExtra: (Int, ExtrasItem) -> Unit,
|
onClickExtra: (Int, ExtrasItem) -> Unit,
|
||||||
|
onClickDiscover: (Int, DiscoverItem) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var position by rememberInt(0)
|
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 dto = movie.data
|
||||||
val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO
|
val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO
|
||||||
|
|
||||||
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
focusRequesters.getOrNull(position)?.tryRequestFocus()
|
RequestOrRestoreFocus(focusRequesters.getOrNull(position))
|
||||||
}
|
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
|
@ -438,6 +451,11 @@ fun MovieDetailsContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
trailers = trailers,
|
||||||
|
trailerOnClick = {
|
||||||
|
position = TRAILER_ROW
|
||||||
|
trailerOnClick.invoke(it)
|
||||||
|
},
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.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()) {
|
if (chapters.isNotEmpty()) {
|
||||||
item {
|
item {
|
||||||
ChapterRow(
|
ChapterRow(
|
||||||
|
|
@ -515,6 +518,14 @@ fun MovieDetailsContent(
|
||||||
}
|
}
|
||||||
if (similar.isNotEmpty()) {
|
if (similar.isNotEmpty()) {
|
||||||
item {
|
item {
|
||||||
|
val imageHeight =
|
||||||
|
remember(movie.type) {
|
||||||
|
if (movie.type == BaseItemKind.MOVIE) {
|
||||||
|
Cards.height2x3
|
||||||
|
} else {
|
||||||
|
Cards.heightEpisode
|
||||||
|
}
|
||||||
|
}
|
||||||
ItemRow(
|
ItemRow(
|
||||||
title = stringResource(R.string.more_like_this),
|
title = stringResource(R.string.more_like_this),
|
||||||
items = similar,
|
items = similar,
|
||||||
|
|
@ -533,7 +544,7 @@ fun MovieDetailsContent(
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
modifier = mod,
|
modifier = mod,
|
||||||
showImageOverlay = true,
|
showImageOverlay = true,
|
||||||
imageHeight = Cards.height2x3,
|
imageHeight = imageHeight,
|
||||||
imageWidth = Dp.Unspecified,
|
imageWidth = Dp.Unspecified,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -544,79 +555,22 @@ fun MovieDetailsContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
if (discovered.isNotEmpty()) {
|
||||||
}
|
item {
|
||||||
}
|
DiscoverRow(
|
||||||
|
row =
|
||||||
@Composable
|
DiscoverRowData(
|
||||||
fun TrailerRow(
|
stringResource(R.string.discover),
|
||||||
trailers: List<Trailer>,
|
DataLoadingState.Success(discovered),
|
||||||
onClickTrailer: (Trailer) -> Unit,
|
),
|
||||||
modifier: Modifier = Modifier,
|
onClickItem = { index: Int, item: DiscoverItem ->
|
||||||
) {
|
position = DISCOVER_ROW
|
||||||
val state = rememberLazyListState()
|
onClickDiscover.invoke(index, item)
|
||||||
val firstFocus = remember { FocusRequester() }
|
},
|
||||||
Column(
|
onLongClickItem = { _, _ -> },
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
onCardFocus = {},
|
||||||
modifier = modifier,
|
focusRequester = focusRequesters[DISCOVER_ROW],
|
||||||
) {
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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