Merge branch 'main' into fea/libass-android-support

This commit is contained in:
Damontecres 2026-01-18 19:16:18 -05:00
commit 6f17abce3a
No known key found for this signature in database
179 changed files with 22968 additions and 2499 deletions

View file

@ -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

View file

@ -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

View file

@ -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)
} }

View file

@ -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')"
]
}
}

View file

@ -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>

View file

@ -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

View file

@ -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

View file

@ -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
}

View file

@ -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 {

View file

@ -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(

View file

@ -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(

View file

@ -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 }

View file

@ -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)
} }

View file

@ -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?
}

View file

@ -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()

View file

@ -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,
)

View file

@ -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,
)
}

View file

@ -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
} }

View file

@ -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?,

View file

@ -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)
}

View file

@ -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>,
)

View file

@ -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

View file

@ -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(

View file

@ -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

View file

@ -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,

View file

@ -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,
) )

View file

@ -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)

View file

@ -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
}
}

View file

@ -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)
} }
} }

View file

@ -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()
} }
} }

View file

@ -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)
}
}
}
}

View file

@ -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,
)
} }

View file

@ -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)
}
}

View file

@ -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)
}
}
}
}
}
}
}

View file

@ -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
}
}

View file

@ -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)

View file

@ -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()
} }
} }
}

View file

@ -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,
), ),
) )

View file

@ -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 =

View file

@ -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)
} }

View file

@ -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(

View file

@ -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(),
)
}
}
}
}
}

View file

@ -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())
}
}
}

View file

@ -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(

View file

@ -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")
}
}
}
}

View file

@ -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
} }

View file

@ -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()
}
}
}

View file

@ -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

View file

@ -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
) )
}, },

View file

@ -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(),
) )

View file

@ -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 =

View file

@ -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,

View file

@ -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,
) )
}
}
}
}

View file

@ -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),
)
}
}

View file

@ -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

View file

@ -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

View file

@ -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
@ -896,6 +957,8 @@ fun CollectionFolderGridContent(
} }
} }
} }
}
}
data class PositionItem( data class PositionItem(
val position: Int, val position: Int,

View file

@ -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) {
@ -285,6 +288,7 @@ fun DialogPopupContent(
} }
} }
} }
}
@Composable @Composable
fun DialogPopup( fun DialogPopup(
@ -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) },
) )

View file

@ -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,
)
}
}
}

View file

@ -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,

View file

@ -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))
}
}

View file

@ -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,
) )
} }

View file

@ -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,
)
}

View file

@ -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,
) )

View file

@ -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),
)
}
}

View file

@ -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,
)
}

View file

@ -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(

View file

@ -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,

View file

@ -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,
)
}

View file

@ -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
}

View file

@ -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
}
}

View file

@ -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),
)
}
}
}

View file

@ -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,35 @@ 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 = "$label: ",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
if (additional.isNotEmpty()) {
Column(modifier = Modifier.weight(1f, fill = false)) {
additional.forEach { (label, value) ->
Row(
modifier = Modifier,
) { ) {
Text( Text(
text = "$label: ", text = "$label: ",
@ -212,46 +253,43 @@ private fun MediaInfoSection(
} }
} }
} }
}
}
}
fun titleIndex(
title: String,
index: Int,
total: Int,
) = if (total > 1) {
"$title (${index + 1})"
} else {
title
}
private fun buildVideoStreamInfo( private fun buildVideoStreamInfo(
context: Context, context: Context,
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(

View file

@ -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') {
@ -399,6 +415,7 @@ fun <T : CardGridItem> CardGrid(
} }
} }
} }
}
@Composable @Composable
fun JumpButtons( fun JumpButtons(

View file

@ -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(),

View file

@ -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)

View file

@ -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)

View file

@ -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(),

View file

@ -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)

View file

@ -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(),

View file

@ -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(

View file

@ -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),
) )
} }

View file

@ -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) {

View file

@ -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,
)
}
} }
} }
} }

View file

@ -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,
) )

View file

@ -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,
)
}
}
}
}
}
}

View file

@ -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,
)
}
}
}
}

View file

@ -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
)

View file

@ -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(),
)
}
}
}
}
}

View file

@ -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,
)
}

View file

@ -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()
}
}
}
}

View file

@ -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),
// )
// }
}
}

View file

@ -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()

View file

@ -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 ->

View file

@ -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)
}
}
}
} }

View file

@ -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,

View file

@ -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)

View file

@ -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
} }

View file

@ -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))
} }

View file

@ -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,81 +555,24 @@ 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