mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Merge branch 'main' into fea/libass-android-support
This commit is contained in:
commit
271cb0ca20
230 changed files with 20792 additions and 2528 deletions
|
|
@ -1,6 +1,4 @@
|
|||
name: "Feature Request"
|
||||
description: Request a new feature
|
||||
title: "[FEA] - <title>"
|
||||
title: "<title>"
|
||||
labels: [
|
||||
"enhancement"
|
||||
]
|
||||
6
.github/ISSUE_TEMPLATE/00-bug.yml
vendored
6
.github/ISSUE_TEMPLATE/00-bug.yml
vendored
|
|
@ -1,6 +1,6 @@
|
|||
name: "Bug Report"
|
||||
description: Report a bug
|
||||
title: "[BUG] - <title>"
|
||||
title: "<title>"
|
||||
labels: [
|
||||
"bug"
|
||||
]
|
||||
|
|
@ -28,7 +28,7 @@ body:
|
|||
attributes:
|
||||
label: "App Version"
|
||||
description: What version of the app?
|
||||
placeholder: "v0.3.0"
|
||||
placeholder: "v0.5.1"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
|
|
@ -36,7 +36,7 @@ body:
|
|||
attributes:
|
||||
label: "Server Version"
|
||||
description: What version of the server?
|
||||
placeholder: "10.11.3"
|
||||
placeholder: "10.11.6"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
|
|
|
|||
7
.github/ISSUE_TEMPLATE/01-playback.yml
vendored
7
.github/ISSUE_TEMPLATE/01-playback.yml
vendored
|
|
@ -1,6 +1,6 @@
|
|||
name: "Playback Problem"
|
||||
description: Report an issue with playback
|
||||
title: "[BUG] - <title>"
|
||||
title: "<title>"
|
||||
labels: [
|
||||
"bug", "playback"
|
||||
]
|
||||
|
|
@ -17,6 +17,7 @@ body:
|
|||
attributes:
|
||||
label: "Media info"
|
||||
description: Please enter details about what media you are trying to play (video or audio codecs, container, etc)
|
||||
placeholder: Use mediainfo or "Send media info to server" in the app to gather this
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
|
@ -35,7 +36,7 @@ body:
|
|||
attributes:
|
||||
label: "App Version"
|
||||
description: What version of the app?
|
||||
placeholder: "v0.3.0"
|
||||
placeholder: "v0.5.1"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
|
|
@ -43,7 +44,7 @@ body:
|
|||
attributes:
|
||||
label: "Server Version"
|
||||
description: What version of the server?
|
||||
placeholder: "10.11.3"
|
||||
placeholder: "10.11.6"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
|
|
|
|||
15
.github/ISSUE_TEMPLATE/03-question.yml
vendored
15
.github/ISSUE_TEMPLATE/03-question.yml
vendored
|
|
@ -1,15 +0,0 @@
|
|||
name: "Question"
|
||||
description: Ask a question
|
||||
title: "[QST] - <title>"
|
||||
labels: [
|
||||
"question"
|
||||
]
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: "Question"
|
||||
description: Please enter your question
|
||||
placeholder: "How do I...?"
|
||||
validations:
|
||||
required: true
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: New feature / UI suggestion
|
||||
url: https://github.com/damontecres/Wholphin/discussions/new?category=ideas
|
||||
about: Please request new features here
|
||||
- name: Questions
|
||||
url: https://github.com/damontecres/Wholphin/discussions/new?category=q-a
|
||||
about: Please ask questions here
|
||||
8
.github/actions/native-build/action.yml
vendored
8
.github/actions/native-build/action.yml
vendored
|
|
@ -10,7 +10,7 @@ runs:
|
|||
- name: Load ffmpeg module cache
|
||||
id: cache_ffmpeg_module
|
||||
if: ${{ inputs.cache }}
|
||||
uses: actions/cache/restore@v4
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: |
|
||||
app/libs
|
||||
|
|
@ -18,7 +18,7 @@ runs:
|
|||
- name: Load libmpv module cache
|
||||
id: cache_libmpv_module
|
||||
if: ${{ inputs.cache }}
|
||||
uses: actions/cache/restore@v4
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: |
|
||||
app/src/main/libs
|
||||
|
|
@ -43,7 +43,7 @@ runs:
|
|||
./build_ffmpeg_decoder.sh "${{ env.ANDROID_SDK_ROOT }}/ndk/${{ env.NDK_VERSION }}"
|
||||
- name: Save ffmpeg module cache
|
||||
id: cache_ffmpeg_module_save
|
||||
uses: actions/cache/save@v4
|
||||
uses: actions/cache/save@v5
|
||||
with:
|
||||
path: |
|
||||
app/libs
|
||||
|
|
@ -75,7 +75,7 @@ runs:
|
|||
#ln -s libs jniLibs
|
||||
- name: Save libmpv module cache
|
||||
id: cache_libmpv_module_save
|
||||
uses: actions/cache/save@v4
|
||||
uses: actions/cache/save@v5
|
||||
with:
|
||||
path: |
|
||||
app/src/main/libs
|
||||
|
|
|
|||
4
.github/actions/setup/action.yml
vendored
4
.github/actions/setup/action.yml
vendored
|
|
@ -6,9 +6,9 @@ runs:
|
|||
steps:
|
||||
# Setup the SDKs
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
python-version: '3.14'
|
||||
- name: Setup JDK
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
|
|
|
|||
2
.github/workflows/main.yml
vendored
2
.github/workflows/main.yml
vendored
|
|
@ -25,7 +25,7 @@ jobs:
|
|||
contents: write
|
||||
steps:
|
||||
- name: Checkout the code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # Need the tags to build
|
||||
- name: Setup
|
||||
|
|
|
|||
33
.github/workflows/pr.yml
vendored
33
.github/workflows/pr.yml
vendored
|
|
@ -16,13 +16,13 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # Need the tags to build
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
python-version: '3.14'
|
||||
- name: Run pre-commit
|
||||
uses: pre-commit/action@v3.0.1
|
||||
|
||||
|
|
@ -31,7 +31,7 @@ jobs:
|
|||
needs: pre-commit
|
||||
steps:
|
||||
- name: Checkout the code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # Need the tags to build
|
||||
- name: Setup
|
||||
|
|
@ -44,19 +44,18 @@ jobs:
|
|||
id: buildapp
|
||||
run: |
|
||||
./gradlew clean assembleDebug testDebugUnitTest --no-daemon
|
||||
apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//')
|
||||
apks=$(find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | tr '\0' ',' | sed 's/,$//')
|
||||
echo "apks=$apks" >> "$GITHUB_OUTPUT"
|
||||
- name: Tar build dirs
|
||||
|
||||
test-patch:
|
||||
runs-on: ubuntu-latest
|
||||
needs: pre-commit
|
||||
steps:
|
||||
- name: Checkout the code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Test applying patch
|
||||
run: |
|
||||
tar -czf build.tgz ./app/.
|
||||
- uses: actions/upload-artifact@v5
|
||||
id: upload-build-dirs
|
||||
with:
|
||||
name: "${{ env.BUILD_DIRS_ARTIFACT }}"
|
||||
path: build.tgz
|
||||
if-no-files-found: error
|
||||
- uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: APKs
|
||||
path: "${{ steps.buildapp.outputs.apks }}"
|
||||
compression-level: 0
|
||||
git apply app/src/patches/play_store.patch
|
||||
git diff
|
||||
|
|
|
|||
30
.github/workflows/release.yml
vendored
30
.github/workflows/release.yml
vendored
|
|
@ -21,7 +21,7 @@ jobs:
|
|||
contents: write
|
||||
steps:
|
||||
- name: Checkout the code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # Need the tags to build
|
||||
- name: Setup
|
||||
|
|
@ -39,11 +39,24 @@ jobs:
|
|||
SIGNING_KEY: "${{ secrets.SIGNING_KEY }}"
|
||||
run: |
|
||||
./gradlew clean assembleRelease --no-daemon
|
||||
|
||||
- name: Build app
|
||||
id: buildaab
|
||||
env:
|
||||
KEY_ALIAS: "${{ secrets.KEY_ALIAS }}"
|
||||
KEY_PASSWORD: "${{ secrets.KEY_PASSWORD }}"
|
||||
KEY_STORE_PASSWORD: "${{ secrets.KEY_STORE_PASSWORD }}"
|
||||
SIGNING_KEY: "${{ secrets.SIGNING_KEY }}"
|
||||
run: |
|
||||
git apply app/src/patches/play_store.patch
|
||||
./gradlew bundleRelease --no-daemon
|
||||
aab=$(find app/build/outputs -name '*.aab')
|
||||
echo "aab=$aab" >> "$GITHUB_OUTPUT"
|
||||
- name: Verify signatures
|
||||
run: |
|
||||
echo "Verify APK signatures"
|
||||
find app/build/outputs/apk -name '*.apk'
|
||||
find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 -n1 ${{env.ANDROID_SDK_ROOT}}/build-tools/${{ env.BUILD_TOOLS_VERSION }}/apksigner verify --verbose --print-certs
|
||||
find app/build/outputs \( -name '*.apk' -or -name '*.aab' \)
|
||||
find app/build/outputs \( -name '*.apk' \) -print0 | xargs -0 -n1 ${{env.ANDROID_SDK_ROOT}}/build-tools/${{ env.BUILD_TOOLS_VERSION }}/apksigner verify --verbose --print-certs
|
||||
- name: Copy APK to shorter names
|
||||
id: apks
|
||||
run: |
|
||||
|
|
@ -59,11 +72,18 @@ jobs:
|
|||
- name: Checksums
|
||||
run: |
|
||||
echo "SHA256 checksums:"
|
||||
find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 sha256sum
|
||||
find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 sha256sum
|
||||
- name: Upload AAB
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: AAB
|
||||
path: |
|
||||
app/build/outputs/bundle/**/*.aab
|
||||
compression-level: 0
|
||||
- name: Create GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release create "${{ env.TAG_NAME }}" \
|
||||
--latest --title "${{ env.TAG_NAME }}" --verify-tag -n "Placeholder" --draft \
|
||||
--latest --title "${{ env.TAG_NAME }}" --verify-tag -n "" --draft \
|
||||
"app/build/outputs/apk/**/*.apk"
|
||||
|
|
|
|||
19
README.md
19
README.md
|
|
@ -24,12 +24,14 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin
|
|||
|
||||
</p>
|
||||
|
||||
<img width="1280" height="720" alt="0_3_5_home" src="https://github.com/user-attachments/assets/a485c015-ec21-442d-a757-1f18381bf799" />
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
### User interface
|
||||
|
||||
- Customize the home page to see the content you are interested in
|
||||
- Use poster or thumb images, show/hide titles, add/remove/re-order different types of rows!
|
||||
- A navigation drawer for quick access to libraries, favorites, search, and settings from almost anywhere in the app
|
||||
- Integration with [Jellyseerr](https://github.com/seerr-team/seerr) to discover new movies and TV shows
|
||||
- Option to combine Continue Watching & Next Up rows
|
||||
|
|
@ -112,14 +114,21 @@ You can [help translate Wholphin](https://translate.codeberg.org/engage/wholphin
|
|||
|
||||
## Additional screenshots
|
||||
|
||||
### Customized home page
|
||||

|
||||
|
||||
### Movie library browsing
|
||||
<img width="1280" height="771" alt="0 3 0_movies" src="https://github.com/user-attachments/assets/a49829b5-bc2c-4af9-8d5d-2f7d0973ce01" />
|
||||

|
||||
|
||||
### Movie page
|
||||
<img width="1280" height="720" alt="0_3_5_movie" src="https://github.com/user-attachments/assets/86af5889-6761-426a-8649-422f9d0a1dc0" />
|
||||

|
||||
|
||||
### Series page
|
||||
<img width="1280" height="720" alt="0_3_5_series" src="https://github.com/user-attachments/assets/2dcb2260-53ce-49d6-9088-72cbd4563c48" />
|
||||

|
||||
|
||||
### Genres in library
|
||||

|
||||
|
||||
|
||||
### Playlist
|
||||
<img width="1280" height="771" alt="0 3 0_playlist" src="https://github.com/user-attachments/assets/7ca589ab-9c88-483a-b769-35ffb5663d9e" />
|
||||

|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import java.util.Properties
|
|||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.hilt)
|
||||
|
|
@ -45,42 +44,9 @@ android {
|
|||
targetSdk = 36
|
||||
versionCode = gitTags.trim().lines().size
|
||||
versionName = gitDescribe.trim().removePrefix("v").ifBlank { "0.0.0" }
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
isDebuggable = false
|
||||
}
|
||||
debug {
|
||||
isMinifyEnabled = false
|
||||
isDebuggable = true
|
||||
applicationIdSuffix = ".debug"
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = JvmTarget.JVM_11
|
||||
javaParameters = true
|
||||
}
|
||||
}
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
compose = true
|
||||
}
|
||||
room {
|
||||
schemaDirectory("$projectDir/schemas")
|
||||
}
|
||||
signingConfigs {
|
||||
if (shouldSign) {
|
||||
create("ci") {
|
||||
|
|
@ -98,14 +64,15 @@ android {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
isDebuggable = false
|
||||
if (shouldSign) {
|
||||
signingConfig = signingConfigs.getByName("ci")
|
||||
} else {
|
||||
|
|
@ -120,10 +87,11 @@ android {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug {
|
||||
if (shouldSign) {
|
||||
signingConfig = signingConfigs.getByName("ci")
|
||||
}
|
||||
isMinifyEnabled = false
|
||||
isDebuggable = true
|
||||
applicationIdSuffix = ".debug"
|
||||
}
|
||||
|
||||
applicationVariants.all {
|
||||
|
|
@ -138,6 +106,25 @@ android {
|
|||
}
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3
|
||||
jvmTarget = JvmTarget.JVM_11
|
||||
javaParameters = true
|
||||
}
|
||||
}
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
compose = true
|
||||
}
|
||||
room {
|
||||
schemaDirectory("$projectDir/schemas")
|
||||
}
|
||||
|
||||
splits {
|
||||
abi {
|
||||
|
|
@ -150,7 +137,13 @@ android {
|
|||
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
kotlin.srcDirs("$buildDir/generated/seerr_api/src/main/kotlin")
|
||||
kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin"
|
||||
}
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -223,6 +216,7 @@ dependencies {
|
|||
implementation(libs.androidx.tv.foundation)
|
||||
implementation(libs.androidx.tv.material)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.livedata.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.datastore)
|
||||
implementation(libs.protobuf.kotlin.lite)
|
||||
|
|
@ -266,6 +260,8 @@ dependencies {
|
|||
implementation(libs.androidx.preference.ktx)
|
||||
implementation(libs.androidx.room.testing)
|
||||
implementation(libs.androidx.palette.ktx)
|
||||
implementation(libs.androidx.media3.effect)
|
||||
implementation(libs.androidx.runner)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
ksp(libs.hilt.android.compiler)
|
||||
ksp(libs.androidx.hilt.compiler)
|
||||
|
|
@ -284,6 +280,8 @@ dependencies {
|
|||
ksp(libs.auto.service.ksp)
|
||||
implementation(platform(libs.okhttp.bom))
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.kache)
|
||||
implementation(libs.kache.file)
|
||||
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
|
|
@ -299,5 +297,12 @@ dependencies {
|
|||
|
||||
testImplementation(libs.mockk.android)
|
||||
testImplementation(libs.mockk.agent)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.androidx.core.testing)
|
||||
testImplementation(libs.robolectric)
|
||||
testImplementation(libs.hilt.android.testing)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
androidTestImplementation(libs.mockk.android)
|
||||
androidTestImplementation(libs.hilt.android.testing)
|
||||
androidTestImplementation(libs.androidx.ui.test.manifest)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,635 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 30,
|
||||
"identityHash": "f9b86b73e972aa002238d43ab2c8dcc2",
|
||||
"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": "playback_effects",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "jellyfinUserRowId",
|
||||
"columnName": "jellyfinUserRowId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "itemId",
|
||||
"columnName": "itemId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "videoFilter.rotation",
|
||||
"columnName": "rotation",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "videoFilter.brightness",
|
||||
"columnName": "brightness",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "videoFilter.contrast",
|
||||
"columnName": "contrast",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "videoFilter.saturation",
|
||||
"columnName": "saturation",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "videoFilter.hue",
|
||||
"columnName": "hue",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "videoFilter.red",
|
||||
"columnName": "red",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "videoFilter.green",
|
||||
"columnName": "green",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "videoFilter.blue",
|
||||
"columnName": "blue",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "videoFilter.blur",
|
||||
"columnName": "blur",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"jellyfinUserRowId",
|
||||
"itemId",
|
||||
"type"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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, 'f9b86b73e972aa002238d43ab2c8dcc2')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,642 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 31,
|
||||
"identityHash": "c6829d764ec85321ab3be9905d6c0e3a",
|
||||
"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, `order` INTEGER NOT NULL DEFAULT -1, 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
|
||||
},
|
||||
{
|
||||
"fieldPath": "order",
|
||||
"columnName": "order",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "-1"
|
||||
}
|
||||
],
|
||||
"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": "playback_effects",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "jellyfinUserRowId",
|
||||
"columnName": "jellyfinUserRowId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "itemId",
|
||||
"columnName": "itemId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "videoFilter.rotation",
|
||||
"columnName": "rotation",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "videoFilter.brightness",
|
||||
"columnName": "brightness",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "videoFilter.contrast",
|
||||
"columnName": "contrast",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "videoFilter.saturation",
|
||||
"columnName": "saturation",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "videoFilter.hue",
|
||||
"columnName": "hue",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "videoFilter.red",
|
||||
"columnName": "red",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "videoFilter.green",
|
||||
"columnName": "green",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "videoFilter.blue",
|
||||
"columnName": "blue",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "videoFilter.blur",
|
||||
"columnName": "blur",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"jellyfinUserRowId",
|
||||
"itemId",
|
||||
"type"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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, 'c6829d764ec85321ab3be9905d6c0e3a')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
package com.github.damontecres.wholphin.test
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.SemanticsNodeInteraction
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import com.github.damontecres.wholphin.MainContent
|
||||
import com.github.damontecres.wholphin.services.ScreensaverService
|
||||
import com.github.damontecres.wholphin.services.ScreensaverState
|
||||
import com.github.damontecres.wholphin.services.SetupDestination
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@HiltAndroidTest
|
||||
class InstrumentedBasicUiTests {
|
||||
@get:Rule(order = 0)
|
||||
var hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule(order = 1)
|
||||
val composeTestRule = createAndroidComposeRule<TestActivity>()
|
||||
|
||||
lateinit var screensaverService: ScreensaverService
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
screensaverService = mockk(relaxed = true)
|
||||
every { screensaverService.state } returns MutableStateFlow(ScreensaverState(false, false, false, false))
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
@Test
|
||||
fun myTest() {
|
||||
// Start the app
|
||||
composeTestRule.setContent {
|
||||
WholphinTheme {
|
||||
MainContent(
|
||||
backStack = mutableListOf(SetupDestination.ServerList),
|
||||
navigationManager = mockk(relaxed = true),
|
||||
appPreferences = mockk(relaxed = true),
|
||||
backdropService = mockk(relaxed = true),
|
||||
screensaverService = screensaverService,
|
||||
requestedDestination = Destination.Home(),
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Add Server").assertExists()
|
||||
composeTestRule.onNodeWithTag("add_server").performKeyInput {
|
||||
pressKey(Key.DirectionDown) // TODO
|
||||
}
|
||||
composeTestRule.onNodeWithTag("add_server").performClickEnter()
|
||||
|
||||
composeTestRule.onNodeWithText("Discovered Servers").assertExists()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
fun SemanticsNodeInteraction.performClickEnter() =
|
||||
performKeyInput {
|
||||
pressKey(Key.DirectionCenter)
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.github.damontecres.wholphin.test
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
@AndroidEntryPoint
|
||||
class TestActivity : ComponentActivity()
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package com.github.damontecres.wholphin.test
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.test.runner.AndroidJUnitRunner
|
||||
import dagger.hilt.android.testing.HiltTestApplication
|
||||
|
||||
class WholphinTestRunner : AndroidJUnitRunner() {
|
||||
override fun newApplication(
|
||||
cl: ClassLoader?,
|
||||
name: String?,
|
||||
context: Context?,
|
||||
): Application = super.newApplication(cl, HiltTestApplication::class.java.name, context)
|
||||
}
|
||||
94
app/src/debug/AndroidManifest.xml
Normal file
94
app/src/debug/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:installLocation="auto">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.microphone"
|
||||
android:required="false" />
|
||||
|
||||
<!-- Required for Android 11+ to query voice recognition availability -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.speech.action.RECOGNIZE_SPEECH" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:banner="@mipmap/ic_banner"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Wholphin"
|
||||
android:name=".WholphinApplication"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity android:name=".test.TestActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.provider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/provider_paths" />
|
||||
</provider>
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
tools:node="remove" />
|
||||
|
||||
<!-- <service-->
|
||||
<!-- android:name=".WholphinDreamService"-->
|
||||
<!-- android:exported="true"-->
|
||||
<!-- android:label="@string/app_name"-->
|
||||
<!-- android:permission="android.permission.BIND_DREAM_SERVICE">-->
|
||||
<!-- <intent-filter>-->
|
||||
<!-- <action android:name="android.service.dreams.DreamService" />-->
|
||||
<!-- <category android:name="android.intent.category.DEFAULT" />-->
|
||||
<!-- </intent-filter>-->
|
||||
<!-- </service>-->
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
@ -68,6 +68,17 @@
|
|||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
tools:node="remove" />
|
||||
|
||||
<service
|
||||
android:name=".WholphinDreamService"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:permission="android.permission.BIND_DREAM_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.dreams.DreamService" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.github.damontecres.wholphin
|
|||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
|
|
@ -10,8 +11,6 @@ import androidx.appcompat.app.AppCompatActivity
|
|||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
|
|
@ -19,27 +18,16 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||
import androidx.navigation3.runtime.NavEntry
|
||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Surface
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.AppUpgradeHandler
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.DatePlayedInvalidationService
|
||||
|
|
@ -48,27 +36,36 @@ import com.github.damontecres.wholphin.services.ImageUrlService
|
|||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver
|
||||
import com.github.damontecres.wholphin.services.RefreshRateService
|
||||
import com.github.damontecres.wholphin.services.ScreensaverService
|
||||
import com.github.damontecres.wholphin.services.ServerEventListener
|
||||
import com.github.damontecres.wholphin.services.SetupDestination
|
||||
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
||||
import com.github.damontecres.wholphin.services.SuggestionsSchedulerService
|
||||
import com.github.damontecres.wholphin.services.UpdateChecker
|
||||
import com.github.damontecres.wholphin.services.UserSwitchListener
|
||||
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
||||
import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService
|
||||
import com.github.damontecres.wholphin.ui.CoilConfig
|
||||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.nav.ApplicationContent
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
|
||||
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
|
||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
|
||||
import com.github.damontecres.wholphin.util.DebugLogTree
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
|
|
@ -113,6 +110,12 @@ class MainActivity : AppCompatActivity() {
|
|||
@Inject
|
||||
lateinit var tvProviderSchedulerService: TvProviderSchedulerService
|
||||
|
||||
@Inject
|
||||
lateinit var suggestionsSchedulerService: SuggestionsSchedulerService
|
||||
|
||||
@Inject
|
||||
lateinit var backdropService: BackdropService
|
||||
|
||||
// Note: unused but injected to ensure it is created
|
||||
@Inject
|
||||
lateinit var serverEventListener: ServerEventListener
|
||||
|
|
@ -121,22 +124,20 @@ class MainActivity : AppCompatActivity() {
|
|||
@Inject
|
||||
lateinit var datePlayedInvalidationService: DatePlayedInvalidationService
|
||||
|
||||
@Inject
|
||||
lateinit var screensaverService: ScreensaverService
|
||||
|
||||
private var signInAuto = true
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
instance = this
|
||||
Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}")
|
||||
lifecycle.addObserver(playbackLifecycleObserver)
|
||||
if (savedInstanceState == null) {
|
||||
appUpgradeHandler.copySubfont(false)
|
||||
}
|
||||
refreshRateService.refreshRateMode.observe(this) { modeId ->
|
||||
// Listen for refresh rate changes
|
||||
val attrs = window.attributes
|
||||
if (attrs.preferredDisplayModeId != modeId) {
|
||||
Timber.d("Switch preferredDisplayModeId to %s", modeId)
|
||||
window.attributes = attrs.apply { preferredDisplayModeId = modeId }
|
||||
lifecycleScope.launchIO {
|
||||
appUpgradeHandler.copySubfont(false)
|
||||
}
|
||||
}
|
||||
viewModel.serverRepository.currentUser.observe(this) { user ->
|
||||
|
|
@ -149,9 +150,42 @@ class MainActivity : AppCompatActivity() {
|
|||
window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
screensaverService.keepScreenOn
|
||||
.onEach { keepScreenOn ->
|
||||
Timber.v("keepScreenOn: %s", keepScreenOn)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (keepScreenOn) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} else {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
}.catch { ex ->
|
||||
Timber.e(ex, "Error with keepScreenOn")
|
||||
}.launchIn(lifecycleScope)
|
||||
|
||||
viewModel.appStart()
|
||||
setContent {
|
||||
val appPreferences by userPreferencesDataStore.data.collectAsState(null)
|
||||
if (appPreferences == null) {
|
||||
// Show loading page if it is taking a while to get app preferences
|
||||
var showLoading by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
delay(500)
|
||||
Timber.i("Showing loading page")
|
||||
showLoading = true
|
||||
}
|
||||
if (showLoading) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black),
|
||||
) {
|
||||
LoadingPage()
|
||||
}
|
||||
}
|
||||
}
|
||||
appPreferences?.let { appPreferences ->
|
||||
LaunchedEffect(appPreferences.signInAutomatically) {
|
||||
signInAuto = appPreferences.signInAutomatically
|
||||
|
|
@ -177,127 +211,41 @@ class MainActivity : AppCompatActivity() {
|
|||
true,
|
||||
appThemeColors = appPreferences.interfacePreferences.appThemeColors,
|
||||
) {
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
shape = RectangleShape,
|
||||
) {
|
||||
// val backStack = rememberNavBackStack(SetupDestination.Loading)
|
||||
// setupNavigationManager.backStack = backStack
|
||||
val backStack = setupNavigationManager.backStack
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
onBack = { backStack.removeLastOrNull() },
|
||||
entryDecorators =
|
||||
listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator(),
|
||||
),
|
||||
entryProvider = { key ->
|
||||
key as SetupDestination
|
||||
NavEntry(key) {
|
||||
when (key) {
|
||||
SetupDestination.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier.size(200.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
color = MaterialTheme.colorScheme.border,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SetupDestination.ServerList -> {
|
||||
SwitchServerContent(Modifier.fillMaxSize())
|
||||
}
|
||||
|
||||
is SetupDestination.UserList -> {
|
||||
SwitchUserContent(
|
||||
currentServer = key.server,
|
||||
Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
||||
is SetupDestination.AppContent -> {
|
||||
val current = key.current
|
||||
ProvideLocalClock {
|
||||
if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) {
|
||||
LaunchedEffect(Unit) {
|
||||
try {
|
||||
updateChecker.maybeShowUpdateToast(
|
||||
appPreferences.updateUrl,
|
||||
)
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(
|
||||
ex,
|
||||
"Exception during update check",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val appPreferences by userPreferencesDataStore.data.collectAsState(
|
||||
appPreferences,
|
||||
)
|
||||
val preferences =
|
||||
remember(appPreferences) {
|
||||
UserPreferences(appPreferences)
|
||||
}
|
||||
var showContent by remember {
|
||||
mutableStateOf(true)
|
||||
}
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
|
||||
if (!preferences.appPreferences.signInAutomatically) {
|
||||
showContent = false
|
||||
}
|
||||
}
|
||||
|
||||
if (showContent) {
|
||||
val requestedDestination =
|
||||
remember(intent) {
|
||||
intent?.let(::extractDestination)
|
||||
}
|
||||
ApplicationContent(
|
||||
user = current.user,
|
||||
server = current.server,
|
||||
startDestination =
|
||||
requestedDestination
|
||||
?: Destination.Home(),
|
||||
navigationManager = navigationManager,
|
||||
preferences = preferences,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier.size(200.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
color = MaterialTheme.colorScheme.border,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
val requestedDestination =
|
||||
remember(intent) {
|
||||
intent?.let(::extractDestination) ?: Destination.Home()
|
||||
}
|
||||
MainContent(
|
||||
backStack = setupNavigationManager.backStack,
|
||||
navigationManager = navigationManager,
|
||||
appPreferences = appPreferences,
|
||||
backdropService = backdropService,
|
||||
screensaverService = screensaverService,
|
||||
requestedDestination = requestedDestination,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
if (screensaverService.state.value.show) {
|
||||
screensaverService.stop(false)
|
||||
screensaverService.pulse()
|
||||
return true
|
||||
} else {
|
||||
screensaverService.pulse()
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
Timber.d("onResume")
|
||||
lifecycleScope.launchIO {
|
||||
lifecycleScope.launchDefault {
|
||||
screensaverService.pulse()
|
||||
appUpgradeHandler.run()
|
||||
}
|
||||
}
|
||||
|
|
@ -311,6 +259,7 @@ class MainActivity : AppCompatActivity() {
|
|||
override fun onStop() {
|
||||
super.onStop()
|
||||
Timber.d("onStop")
|
||||
screensaverService.stop(true)
|
||||
tvProviderSchedulerService.launchOneTimeRefresh()
|
||||
}
|
||||
|
||||
|
|
@ -322,6 +271,22 @@ class MainActivity : AppCompatActivity() {
|
|||
override fun onStart() {
|
||||
super.onStart()
|
||||
Timber.d("onStart")
|
||||
|
||||
lifecycleScope.launchDefault {
|
||||
val appPreferences = userPreferencesDataStore.data.first()
|
||||
if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) {
|
||||
try {
|
||||
updateChecker.maybeShowUpdateToast(
|
||||
appPreferences.updateUrl,
|
||||
)
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(
|
||||
ex,
|
||||
"Exception during update check",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
|
|
@ -384,6 +349,16 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
fun changeDisplayMode(modeId: Int) {
|
||||
lifecycleScope.launch(Dispatchers.Main + ExceptionHandler(autoToast = true)) {
|
||||
val attrs = window.attributes
|
||||
if (attrs.preferredDisplayModeId != modeId) {
|
||||
Timber.d("Switch preferredDisplayModeId to %s", modeId)
|
||||
window.attributes = attrs.apply { preferredDisplayModeId = modeId }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val INTENT_ITEM_ID = "itemId"
|
||||
const val INTENT_ITEM_TYPE = "itemType"
|
||||
|
|
@ -391,6 +366,9 @@ class MainActivity : AppCompatActivity() {
|
|||
const val INTENT_EPISODE_NUMBER = "epNum"
|
||||
const val INTENT_SEASON_NUMBER = "seaNum"
|
||||
const val INTENT_SEASON_ID = "seaId"
|
||||
|
||||
lateinit var instance: MainActivity
|
||||
private set
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
149
app/src/main/java/com/github/damontecres/wholphin/MainContent.kt
Normal file
149
app/src/main/java/com/github/damontecres/wholphin/MainContent.kt
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
package com.github.damontecres.wholphin
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||
import androidx.navigation3.runtime.NavEntry
|
||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Surface
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.ScreensaverService
|
||||
import com.github.damontecres.wholphin.services.SetupDestination
|
||||
import com.github.damontecres.wholphin.ui.components.AppScreensaver
|
||||
import com.github.damontecres.wholphin.ui.nav.ApplicationContent
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
|
||||
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
|
||||
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
|
||||
|
||||
@Composable
|
||||
fun MainContent(
|
||||
backStack: MutableList<SetupDestination>,
|
||||
navigationManager: NavigationManager,
|
||||
appPreferences: AppPreferences,
|
||||
backdropService: BackdropService,
|
||||
screensaverService: ScreensaverService,
|
||||
requestedDestination: Destination,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
modifier =
|
||||
modifier
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
shape = RectangleShape,
|
||||
) {
|
||||
// val backStack = rememberNavBackStack(SetupDestination.Loading)
|
||||
// setupNavigationManager.backStack = backStack
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
onBack = { backStack.removeLastOrNull() },
|
||||
entryDecorators =
|
||||
listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator(),
|
||||
),
|
||||
entryProvider = { key ->
|
||||
key as SetupDestination
|
||||
NavEntry(key) {
|
||||
when (key) {
|
||||
SetupDestination.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier.size(200.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
color = MaterialTheme.colorScheme.border,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SetupDestination.ServerList -> {
|
||||
SwitchServerContent(Modifier.fillMaxSize())
|
||||
}
|
||||
|
||||
is SetupDestination.UserList -> {
|
||||
SwitchUserContent(
|
||||
currentServer = key.server,
|
||||
Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
||||
is SetupDestination.AppContent -> {
|
||||
LaunchedEffect(Unit) {
|
||||
backdropService.clearBackdrop()
|
||||
}
|
||||
val current = key.current
|
||||
ProvideLocalClock {
|
||||
val preferences =
|
||||
remember(appPreferences) {
|
||||
UserPreferences(appPreferences)
|
||||
}
|
||||
var showContent by remember {
|
||||
mutableStateOf(true)
|
||||
}
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
|
||||
if (!appPreferences.signInAutomatically) {
|
||||
showContent = false
|
||||
}
|
||||
}
|
||||
|
||||
if (showContent) {
|
||||
ApplicationContent(
|
||||
user = current.user,
|
||||
server = current.server,
|
||||
startDestination = requestedDestination,
|
||||
navigationManager = navigationManager,
|
||||
preferences = preferences,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier.size(200.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
color = MaterialTheme.colorScheme.border,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
val screenSaverState by screensaverService.state.collectAsState()
|
||||
if (screenSaverState.enabled || screenSaverState.enabledTemp) {
|
||||
AnimatedVisibility(
|
||||
screenSaverState.show,
|
||||
Modifier.fillMaxSize(),
|
||||
) {
|
||||
AppScreensaver(appPreferences, Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@ package com.github.damontecres.wholphin
|
|||
import android.app.Application
|
||||
import android.os.Build
|
||||
import android.os.StrictMode
|
||||
import android.os.StrictMode.ThreadPolicy
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Composer
|
||||
import androidx.compose.runtime.ExperimentalComposeRuntimeApi
|
||||
|
|
@ -27,16 +26,6 @@ class WholphinApplication :
|
|||
init {
|
||||
instance = this
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
StrictMode.setThreadPolicy(
|
||||
ThreadPolicy
|
||||
.Builder()
|
||||
.detectNetwork()
|
||||
.penaltyLog()
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Timber.plant(Timber.DebugTree())
|
||||
} else {
|
||||
|
|
@ -64,6 +53,23 @@ class WholphinApplication :
|
|||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
if (BuildConfig.DEBUG) {
|
||||
StrictMode.setThreadPolicy(
|
||||
StrictMode.ThreadPolicy
|
||||
.Builder()
|
||||
.detectNetwork()
|
||||
.penaltyLog()
|
||||
.penaltyDeathOnNetwork()
|
||||
.build(),
|
||||
)
|
||||
// StrictMode.setVmPolicy(
|
||||
// StrictMode.VmPolicy
|
||||
// .Builder()
|
||||
// .detectAll()
|
||||
// .penaltyLog()
|
||||
// .build(),
|
||||
// )
|
||||
}
|
||||
OkHttp.initialize(this)
|
||||
initAcra {
|
||||
buildConfigClass = BuildConfig::class.java
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
package com.github.damontecres.wholphin
|
||||
|
||||
import android.service.dreams.DreamService
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleRegistry
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.setViewTreeLifecycleOwner
|
||||
import androidx.savedstate.SavedStateRegistry
|
||||
import androidx.savedstate.SavedStateRegistryController
|
||||
import androidx.savedstate.SavedStateRegistryOwner
|
||||
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.ScreensaverService
|
||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.ui.components.AppScreensaverContent
|
||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@AndroidEntryPoint
|
||||
class WholphinDreamService :
|
||||
DreamService(),
|
||||
SavedStateRegistryOwner {
|
||||
@Inject
|
||||
lateinit var screensaverService: ScreensaverService
|
||||
|
||||
@Inject
|
||||
lateinit var userPreferencesService: UserPreferencesService
|
||||
|
||||
private val lifecycleRegistry = LifecycleRegistry(this)
|
||||
|
||||
private val savedStateRegistryController =
|
||||
SavedStateRegistryController.create(this).apply {
|
||||
performAttach()
|
||||
}
|
||||
|
||||
override val lifecycle: Lifecycle get() = lifecycleRegistry
|
||||
override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
savedStateRegistryController.performRestore(null)
|
||||
lifecycleRegistry.currentState = Lifecycle.State.CREATED
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
val itemFlow = screensaverService.createItemFlow(lifecycleScope)
|
||||
setContentView(
|
||||
ComposeView(this).apply {
|
||||
setViewTreeLifecycleOwner(this@WholphinDreamService)
|
||||
setViewTreeSavedStateRegistryOwner(this@WholphinDreamService)
|
||||
setContent {
|
||||
var prefs by remember { mutableStateOf<UserPreferences?>(null) }
|
||||
LaunchedEffect(Unit) {
|
||||
userPreferencesService.flow.collectLatest { prefs = it }
|
||||
}
|
||||
prefs?.let { prefs ->
|
||||
WholphinTheme(appThemeColors = prefs.appPreferences.interfacePreferences.appThemeColors) {
|
||||
ProvideLocalClock {
|
||||
val screensaverPrefs =
|
||||
prefs.appPreferences.interfacePreferences.screensaverPreference
|
||||
val currentItem by itemFlow.collectAsState(null)
|
||||
AppScreensaverContent(
|
||||
currentItem = currentItem,
|
||||
showClock = screensaverPrefs.showClock,
|
||||
duration = screensaverPrefs.duration.milliseconds,
|
||||
animate = screensaverPrefs.animate,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDreamingStarted() {
|
||||
super.onDreamingStarted()
|
||||
lifecycleRegistry.currentState = Lifecycle.State.STARTED
|
||||
}
|
||||
|
||||
override fun onDreamingStopped() {
|
||||
super.onDreamingStopped()
|
||||
lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.data.model.JellyfinServer
|
|||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
|
||||
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
|
||||
import com.github.damontecres.wholphin.data.model.PlaybackEffect
|
||||
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
|
||||
import com.github.damontecres.wholphin.data.model.SeerrServer
|
||||
import com.github.damontecres.wholphin.data.model.SeerrUser
|
||||
|
|
@ -32,12 +33,14 @@ import java.util.UUID
|
|||
ItemPlayback::class,
|
||||
NavDrawerPinnedItem::class,
|
||||
LibraryDisplayInfo::class,
|
||||
PlaybackEffect::class,
|
||||
PlaybackLanguageChoice::class,
|
||||
ItemTrackModification::class,
|
||||
SeerrServer::class,
|
||||
SeerrUser::class,
|
||||
|
||||
],
|
||||
version = 20,
|
||||
version = 31,
|
||||
exportSchema = true,
|
||||
autoMigrations = [
|
||||
AutoMigration(3, 4),
|
||||
|
|
@ -50,6 +53,8 @@ import java.util.UUID
|
|||
AutoMigration(10, 11),
|
||||
AutoMigration(11, 12),
|
||||
AutoMigration(12, 20),
|
||||
AutoMigration(20, 30),
|
||||
AutoMigration(30, 31),
|
||||
],
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
|
|
@ -65,6 +70,8 @@ abstract class AppDatabase : RoomDatabase() {
|
|||
abstract fun playbackLanguageChoiceDao(): PlaybackLanguageChoiceDao
|
||||
|
||||
abstract fun seerrServerDao(): SeerrServerDao
|
||||
|
||||
abstract fun playbackEffectDao(): PlaybackEffectDao
|
||||
}
|
||||
|
||||
class Converters {
|
||||
|
|
|
|||
|
|
@ -1,97 +0,0 @@
|
|||
package com.github.damontecres.wholphin.data
|
||||
|
||||
import android.content.Context
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
|
||||
import com.github.damontecres.wholphin.data.model.NavPinType
|
||||
import com.github.damontecres.wholphin.services.SeerrServerRepository
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
|
||||
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
|
||||
import com.github.damontecres.wholphin.util.supportedCollectionTypes
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class NavDrawerItemRepository
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val api: ApiClient,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val serverPreferencesDao: ServerPreferencesDao,
|
||||
private val seerrServerRepository: SeerrServerRepository,
|
||||
) {
|
||||
suspend fun getNavDrawerItems(): List<NavDrawerItem> {
|
||||
val user = serverRepository.currentUser.value
|
||||
val tvAccess =
|
||||
serverRepository.currentUserDto.value
|
||||
?.policy
|
||||
?.enableLiveTvAccess ?: false
|
||||
val userViews =
|
||||
api.userViewsApi
|
||||
.getUserViews(userId = user?.id)
|
||||
.content.items
|
||||
val recordingFolders =
|
||||
if (tvAccess) {
|
||||
api.liveTvApi
|
||||
.getRecordingFolders(userId = user?.id)
|
||||
.content.items
|
||||
.map { it.id }
|
||||
.toSet()
|
||||
} else {
|
||||
setOf()
|
||||
}
|
||||
|
||||
val builtins =
|
||||
if (seerrServerRepository.active.first()) {
|
||||
listOf(NavDrawerItem.Favorites, NavDrawerItem.Discover)
|
||||
} else {
|
||||
listOf(NavDrawerItem.Favorites)
|
||||
}
|
||||
|
||||
val libraries =
|
||||
userViews
|
||||
.filter { it.collectionType in supportedCollectionTypes || it.id in recordingFolders }
|
||||
.map {
|
||||
val destination =
|
||||
if (it.id in recordingFolders) {
|
||||
Destination.Recordings(it.id)
|
||||
} else {
|
||||
BaseItem.from(it, api).destination()
|
||||
}
|
||||
ServerNavDrawerItem(
|
||||
itemId = it.id,
|
||||
name = it.name ?: it.id.toString(),
|
||||
destination = destination,
|
||||
type = it.collectionType ?: CollectionType.UNKNOWN,
|
||||
)
|
||||
}
|
||||
return builtins + libraries
|
||||
}
|
||||
|
||||
suspend fun getFilteredNavDrawerItems(items: List<NavDrawerItem>): List<NavDrawerItem> {
|
||||
val user = serverRepository.currentUser.value
|
||||
val navDrawerPins =
|
||||
user
|
||||
?.let {
|
||||
serverPreferencesDao.getNavDrawerPinnedItems(it)
|
||||
}.orEmpty()
|
||||
val filtered = items.filter { navDrawerPins.isPinned(it.id) }
|
||||
if (items.size != filtered.size) {
|
||||
// Some were filtered out, check if should include More
|
||||
if (navDrawerPins.isPinned(NavDrawerItem.More.id)) {
|
||||
return filtered + listOf(NavDrawerItem.More)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
}
|
||||
|
||||
fun List<NavDrawerPinnedItem>.isPinned(id: String) = (firstOrNull { it.itemId == id }?.type ?: NavPinType.PINNED) == NavPinType.PINNED
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.github.damontecres.wholphin.data
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import com.github.damontecres.wholphin.data.model.PlaybackEffect
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import java.util.UUID
|
||||
|
||||
@Dao
|
||||
interface PlaybackEffectDao {
|
||||
@Query("SELECT * FROM playback_effects WHERE jellyfinUserRowId=:jellyfinUserRowId AND itemId=:itemId AND type=:type")
|
||||
suspend fun getPlaybackEffect(
|
||||
jellyfinUserRowId: Int,
|
||||
itemId: UUID,
|
||||
type: BaseItemKind,
|
||||
): PlaybackEffect?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(playbackEffect: PlaybackEffect)
|
||||
}
|
||||
|
|
@ -47,7 +47,7 @@ interface SeerrServerDao {
|
|||
suspend fun deleteUser(
|
||||
serverId: Int,
|
||||
jellyfinUserRowId: Int,
|
||||
)
|
||||
): Int
|
||||
|
||||
suspend fun deleteUser(user: SeerrUser) = deleteUser(user.serverId, user.jellyfinUserRowId)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,15 +9,18 @@ import androidx.lifecycle.map
|
|||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.services.hilt.IoDispatcher
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import com.github.damontecres.wholphin.util.EqualityMutableLiveData
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.jellyfin.sdk.Jellyfin
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.quickConnectApi
|
||||
import org.jellyfin.sdk.api.client.extensions.systemApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||
import org.jellyfin.sdk.model.api.AuthenticationResult
|
||||
|
|
@ -40,9 +43,8 @@ class ServerRepository
|
|||
val serverDao: JellyfinServerDao,
|
||||
val apiClient: ApiClient,
|
||||
val userPreferencesDataStore: DataStore<AppPreferences>,
|
||||
@param:IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
) {
|
||||
private val sharedPreferences = getServerSharedPreferences(context)
|
||||
|
||||
private var _current = EqualityMutableLiveData<CurrentUser?>(null)
|
||||
val current: LiveData<CurrentUser?> = _current
|
||||
|
||||
|
|
@ -58,7 +60,7 @@ class ServerRepository
|
|||
* The current user is removed
|
||||
*/
|
||||
suspend fun addAndChangeServer(server: JellyfinServer) {
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(ioDispatcher) {
|
||||
serverDao.addOrUpdateServer(server)
|
||||
}
|
||||
apiClient.update(baseUrl = server.url, accessToken = null)
|
||||
|
|
@ -72,7 +74,7 @@ class ServerRepository
|
|||
server: JellyfinServer,
|
||||
user: JellyfinUser,
|
||||
): CurrentUser? =
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(ioDispatcher) {
|
||||
if (server.id != user.serverId) {
|
||||
throw IllegalStateException("User is not part of the server")
|
||||
}
|
||||
|
|
@ -106,7 +108,7 @@ class ServerRepository
|
|||
_current.value = CurrentUser(updatedServer, updatedUser)
|
||||
_currentUserDto.value = userDto
|
||||
}
|
||||
sharedPreferences.edit(true) {
|
||||
getServerSharedPreferences(context).edit(true) {
|
||||
putString(SERVER_URL_KEY, updatedServer.url)
|
||||
putString(ACCESS_TOKEN_KEY, updatedUser.accessToken)
|
||||
}
|
||||
|
|
@ -127,7 +129,7 @@ class ServerRepository
|
|||
return null
|
||||
}
|
||||
val serverAndUsers =
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(ioDispatcher) {
|
||||
serverDao.getServer(serverId)
|
||||
}
|
||||
if (serverAndUsers != null) {
|
||||
|
|
@ -150,7 +152,7 @@ class ServerRepository
|
|||
}
|
||||
|
||||
suspend fun fetchLastUsedServer(serverId: UUID?): JellyfinServer? =
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(ioDispatcher) {
|
||||
serverId?.let { serverDao.getServer(serverId)?.server }
|
||||
}
|
||||
|
||||
|
|
@ -164,7 +166,7 @@ class ServerRepository
|
|||
suspend fun changeUser(
|
||||
serverUrl: String,
|
||||
authenticationResult: AuthenticationResult,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
) = withContext(ioDispatcher) {
|
||||
val accessToken = authenticationResult.accessToken
|
||||
if (accessToken != null) {
|
||||
val authedUser = authenticationResult.user
|
||||
|
|
@ -214,7 +216,7 @@ class ServerRepository
|
|||
}
|
||||
apiClient.update(accessToken = null)
|
||||
}
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(ioDispatcher) {
|
||||
serverDao.deleteUser(user.serverId, user.id)
|
||||
}
|
||||
}
|
||||
|
|
@ -234,7 +236,7 @@ class ServerRepository
|
|||
}
|
||||
apiClient.update(baseUrl = null, accessToken = null)
|
||||
}
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(ioDispatcher) {
|
||||
serverDao.deleteServer(server.id)
|
||||
}
|
||||
}
|
||||
|
|
@ -253,7 +255,7 @@ class ServerRepository
|
|||
suspend fun setUserPin(
|
||||
user: JellyfinUser,
|
||||
pin: String?,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
) = withContext(ioDispatcher) {
|
||||
val newUser = user.copy(pin = pin)
|
||||
val updatedUser = serverDao.addOrUpdateUser(newUser)
|
||||
if (currentUser.value?.id == updatedUser.id && currentServer.value?.id == user.serverId) {
|
||||
|
|
@ -265,6 +267,17 @@ class ServerRepository
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun authorizeQuickConnect(code: String): Boolean =
|
||||
withContext(ioDispatcher) {
|
||||
val userId = currentUser.value?.id
|
||||
if (userId == null) {
|
||||
Timber.e("No user logged in for Quick Connect authorization")
|
||||
throw IllegalStateException("Must be logged in to authorize Quick Connect")
|
||||
}
|
||||
val response = apiClient.quickConnectApi.authorizeQuickConnect(code, userId)
|
||||
response.content
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun getServerSharedPreferences(context: Context): SharedPreferences =
|
||||
context.getSharedPreferences(
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import org.jellyfin.sdk.model.api.BaseItemDto
|
|||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration
|
||||
|
||||
@Serializable
|
||||
|
|
@ -32,6 +33,8 @@ import kotlin.time.Duration
|
|||
data class BaseItem(
|
||||
val data: BaseItemDto,
|
||||
val useSeriesForPrimary: Boolean,
|
||||
val imageUrlOverride: String? = null,
|
||||
val destinationOverride: Destination? = null,
|
||||
) : CardGridItem {
|
||||
val id get() = data.id
|
||||
|
||||
|
|
@ -69,6 +72,8 @@ data class BaseItem(
|
|||
}
|
||||
}
|
||||
|
||||
val canDelete: Boolean get() = data.canDelete == true
|
||||
|
||||
@Transient
|
||||
val aspectRatio: Float? = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 }
|
||||
|
||||
|
|
@ -91,12 +96,20 @@ data class BaseItem(
|
|||
episodeCornerText =
|
||||
data.indexNumber?.let { "E$it" }
|
||||
?: data.premiereDate?.let(::formatDateTime),
|
||||
episdodeUnplayedCornerText =
|
||||
data.indexNumber?.let { "E$it" }
|
||||
?: data.userData
|
||||
?.unplayedItemCount
|
||||
?.takeIf { it > 0 }
|
||||
?.let { abbreviateNumber(it) },
|
||||
episodeUnplayedCornerText =
|
||||
if (type == BaseItemKind.SERIES ||
|
||||
type == BaseItemKind.SEASON ||
|
||||
type == BaseItemKind.EPISODE ||
|
||||
type == BaseItemKind.BOX_SET
|
||||
) {
|
||||
data.indexNumber?.let { "E$it" }
|
||||
?: data.userData
|
||||
?.unplayedItemCount
|
||||
?.takeIf { it > 0 }
|
||||
?.let { abbreviateNumber(it) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
quickDetails =
|
||||
buildAnnotatedString {
|
||||
val details =
|
||||
|
|
@ -106,6 +119,12 @@ data class BaseItem(
|
|||
data.premiereDate?.let { add(DateFormatter.format(it)) }
|
||||
} else if (type == BaseItemKind.SERIES) {
|
||||
data.seriesProductionYears?.let(::add)
|
||||
} else if (type == BaseItemKind.PHOTO) {
|
||||
if (data.productionYear != null) {
|
||||
add(data.productionYear!!.toString())
|
||||
} else if (data.premiereDate != null) {
|
||||
add(data.premiereDate!!.toLocalDate().toString())
|
||||
}
|
||||
} else {
|
||||
data.productionYear?.let { add(it.toString()) }
|
||||
}
|
||||
|
|
@ -154,7 +173,8 @@ data class BaseItem(
|
|||
it.dayOfMonth.toString().padStart(2, '0')
|
||||
}?.toIntOrNull()
|
||||
|
||||
fun destination(): Destination {
|
||||
fun destination(index: Int? = null): Destination {
|
||||
if (destinationOverride != null) return destinationOverride
|
||||
val result =
|
||||
// Redirect episodes & seasons to their series if possible
|
||||
when (type) {
|
||||
|
|
@ -176,6 +196,25 @@ data class BaseItem(
|
|||
)
|
||||
}
|
||||
|
||||
BaseItemKind.TV_CHANNEL -> {
|
||||
Destination.Playback(
|
||||
itemId = id,
|
||||
positionMs = 0L,
|
||||
)
|
||||
}
|
||||
|
||||
BaseItemKind.PROGRAM -> {
|
||||
val channelId = data.channelId
|
||||
if (channelId != null) {
|
||||
Destination.Playback(
|
||||
itemId = channelId,
|
||||
positionMs = 0L,
|
||||
)
|
||||
} else {
|
||||
Destination.MediaItem(this)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
Destination.MediaItem(this)
|
||||
}
|
||||
|
|
@ -201,6 +240,31 @@ val BaseItemDto.aspectRatioFloat: Float? get() = width?.let { w -> height?.let {
|
|||
@Immutable
|
||||
data class BaseItemUi(
|
||||
val episodeCornerText: String?,
|
||||
val episdodeUnplayedCornerText: String?,
|
||||
val episodeUnplayedCornerText: String?,
|
||||
val quickDetails: AnnotatedString,
|
||||
)
|
||||
|
||||
fun createGenreDestination(
|
||||
genreId: UUID,
|
||||
genreName: String,
|
||||
parentId: UUID,
|
||||
parentName: String?,
|
||||
includeItemTypes: List<BaseItemKind>?,
|
||||
) = Destination.FilteredCollection(
|
||||
itemId = parentId,
|
||||
filter =
|
||||
CollectionFolderFilter(
|
||||
nameOverride =
|
||||
listOfNotNull(
|
||||
genreName,
|
||||
parentName,
|
||||
).joinToString(" "),
|
||||
filter =
|
||||
GetItemsFilter(
|
||||
genres = listOf(genreId),
|
||||
includeItemTypes = includeItemTypes,
|
||||
),
|
||||
useSavedLibraryDisplayInfo = false,
|
||||
),
|
||||
recursive = true,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ data class Chapter(
|
|||
)
|
||||
},
|
||||
)
|
||||
}.orEmpty()
|
||||
}?.sortedBy { it.position }
|
||||
.orEmpty()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,239 @@
|
|||
@file:UseSerializers(UUIDSerializer::class)
|
||||
|
||||
package com.github.damontecres.wholphin.data.model
|
||||
|
||||
import com.github.damontecres.wholphin.preferences.PrefContentScale
|
||||
import com.github.damontecres.wholphin.ui.AspectRatio
|
||||
import com.github.damontecres.wholphin.ui.Cards
|
||||
import com.github.damontecres.wholphin.ui.components.ViewOptionImageType
|
||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.UseSerializers
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
import java.util.UUID
|
||||
|
||||
@Serializable
|
||||
sealed interface HomeRowConfig {
|
||||
val viewOptions: HomeRowViewOptions
|
||||
|
||||
fun updateViewOptions(viewOptions: HomeRowViewOptions): HomeRowConfig
|
||||
|
||||
/**
|
||||
* Continue watching media that the user has started but not finished
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("ContinueWatching")
|
||||
data class ContinueWatching(
|
||||
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
|
||||
) : HomeRowConfig {
|
||||
override fun updateViewOptions(viewOptions: HomeRowViewOptions): ContinueWatching = this.copy(viewOptions = viewOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Next up row for next episodes in a series the user has started
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("NextUp")
|
||||
data class NextUp(
|
||||
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
|
||||
) : HomeRowConfig {
|
||||
override fun updateViewOptions(viewOptions: HomeRowViewOptions): NextUp = this.copy(viewOptions = viewOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined [ContinueWatching] and [NextUp]
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("ContinueWatchingCombined")
|
||||
data class ContinueWatchingCombined(
|
||||
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
|
||||
) : HomeRowConfig {
|
||||
override fun updateViewOptions(viewOptions: HomeRowViewOptions): ContinueWatchingCombined = this.copy(viewOptions = viewOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Media recently added to a library
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("RecentlyAdded")
|
||||
data class RecentlyAdded(
|
||||
val parentId: UUID,
|
||||
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
|
||||
) : HomeRowConfig {
|
||||
override fun updateViewOptions(viewOptions: HomeRowViewOptions): RecentlyAdded = this.copy(viewOptions = viewOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Media recently released (premiere date) in a library
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("RecentlyReleased")
|
||||
data class RecentlyReleased(
|
||||
val parentId: UUID,
|
||||
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
|
||||
) : HomeRowConfig {
|
||||
override fun updateViewOptions(viewOptions: HomeRowViewOptions): RecentlyReleased = this.copy(viewOptions = viewOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Row of a genres in a library
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("Genres")
|
||||
data class Genres(
|
||||
val parentId: UUID,
|
||||
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions.genreDefault,
|
||||
) : HomeRowConfig {
|
||||
override fun updateViewOptions(viewOptions: HomeRowViewOptions): Genres = this.copy(viewOptions = viewOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Favorites for a specific type
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("Favorite")
|
||||
data class Favorite(
|
||||
val kind: BaseItemKind,
|
||||
override val viewOptions: HomeRowViewOptions =
|
||||
if (kind == BaseItemKind.EPISODE) {
|
||||
HomeRowViewOptions(
|
||||
heightDp = Cards.HEIGHT_EPISODE,
|
||||
aspectRatio = AspectRatio.WIDE,
|
||||
)
|
||||
} else {
|
||||
HomeRowViewOptions()
|
||||
},
|
||||
) : HomeRowConfig {
|
||||
override fun updateViewOptions(viewOptions: HomeRowViewOptions): Favorite = this.copy(viewOptions = viewOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Currently recording
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("Recordings")
|
||||
data class Recordings(
|
||||
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
|
||||
) : HomeRowConfig {
|
||||
override fun updateViewOptions(viewOptions: HomeRowViewOptions): Recordings = this.copy(viewOptions = viewOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Programs on now/recommended
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("TvPrograms")
|
||||
data class TvPrograms(
|
||||
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions.liveTvDefault,
|
||||
) : HomeRowConfig {
|
||||
override fun updateViewOptions(viewOptions: HomeRowViewOptions): TvPrograms = this.copy(viewOptions = viewOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Live TV channels
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("TvChannels")
|
||||
data class TvChannels(
|
||||
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions.liveTvDefault,
|
||||
) : HomeRowConfig {
|
||||
override fun updateViewOptions(viewOptions: HomeRowViewOptions): TvChannels = this.copy(viewOptions = viewOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch suggestions from [com.github.damontecres.wholphin.services.SuggestionService] for the given parent ID
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("Suggestions")
|
||||
data class Suggestions(
|
||||
val parentId: UUID,
|
||||
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
|
||||
) : HomeRowConfig {
|
||||
override fun updateViewOptions(viewOptions: HomeRowViewOptions): Suggestions = this.copy(viewOptions = viewOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch by parent ID such as a library, collection, or playlist with optional simple sorting
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("ByParent")
|
||||
data class ByParent(
|
||||
val parentId: UUID,
|
||||
val recursive: Boolean,
|
||||
val sort: SortAndDirection? = null,
|
||||
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
|
||||
) : HomeRowConfig {
|
||||
override fun updateViewOptions(viewOptions: HomeRowViewOptions): ByParent = this.copy(viewOptions = viewOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* An arbitrary [GetItemsRequest] allowing to query for anything
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("GetItems")
|
||||
data class GetItems(
|
||||
val name: String,
|
||||
val getItems: GetItemsRequest,
|
||||
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
|
||||
) : HomeRowConfig {
|
||||
override fun updateViewOptions(viewOptions: HomeRowViewOptions): GetItems = this.copy(viewOptions = viewOptions)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Root class for home page settings
|
||||
*
|
||||
* Contains the list of rows and a version
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("HomePageSettings")
|
||||
data class HomePageSettings(
|
||||
val rows: List<HomeRowConfig>,
|
||||
val version: Int,
|
||||
) {
|
||||
companion object {
|
||||
val EMPTY = HomePageSettings(listOf(), SUPPORTED_HOME_PAGE_SETTINGS_VERSION)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the max version supported by this version of the app
|
||||
*/
|
||||
const val SUPPORTED_HOME_PAGE_SETTINGS_VERSION = 1
|
||||
|
||||
/**
|
||||
* View options for displaying a row
|
||||
*
|
||||
* Allows for changing things like height or aspect ratio
|
||||
*/
|
||||
@Serializable
|
||||
data class HomeRowViewOptions(
|
||||
val heightDp: Int = Cards.HEIGHT_2X3_DP,
|
||||
val spacing: Int = 16,
|
||||
val contentScale: PrefContentScale = PrefContentScale.FILL,
|
||||
val aspectRatio: AspectRatio = AspectRatio.TALL,
|
||||
val imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
|
||||
val showTitles: Boolean = false,
|
||||
val useSeries: Boolean = true,
|
||||
val episodeContentScale: PrefContentScale = PrefContentScale.FILL,
|
||||
val episodeAspectRatio: AspectRatio = AspectRatio.TALL,
|
||||
val episodeImageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
|
||||
) {
|
||||
companion object {
|
||||
val genreDefault =
|
||||
HomeRowViewOptions(
|
||||
heightDp = Cards.HEIGHT_EPISODE,
|
||||
aspectRatio = AspectRatio.WIDE,
|
||||
)
|
||||
|
||||
val liveTvDefault =
|
||||
HomeRowViewOptions(
|
||||
heightDp = 96,
|
||||
aspectRatio = AspectRatio.WIDE,
|
||||
contentScale = PrefContentScale.FIT,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
package com.github.damontecres.wholphin.data.model
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
|
|
@ -19,19 +23,21 @@ data class Person(
|
|||
) {
|
||||
companion object {
|
||||
fun fromDto(
|
||||
context: Context,
|
||||
dto: BaseItemPerson,
|
||||
api: ApiClient,
|
||||
): Person =
|
||||
Person(
|
||||
id = dto.id,
|
||||
name = dto.name,
|
||||
role = dto.role,
|
||||
role = personRole(context, dto.role, dto.type),
|
||||
type = dto.type,
|
||||
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
|
||||
favorite = false,
|
||||
)
|
||||
|
||||
fun fromDto(
|
||||
context: Context,
|
||||
dto: BaseItemPerson,
|
||||
favorite: Boolean,
|
||||
api: ApiClient,
|
||||
|
|
@ -39,10 +45,51 @@ data class Person(
|
|||
Person(
|
||||
id = dto.id,
|
||||
name = dto.name,
|
||||
role = dto.role,
|
||||
role = personRole(context, dto.role, dto.type),
|
||||
type = dto.type,
|
||||
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
|
||||
favorite = favorite,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun personRole(
|
||||
context: Context,
|
||||
role: String?,
|
||||
type: PersonKind,
|
||||
): String? =
|
||||
if (type == PersonKind.ACTOR || type == PersonKind.GUEST_STAR || type == PersonKind.UNKNOWN) {
|
||||
role
|
||||
} else if (role.equals(type.name, ignoreCase = true)) {
|
||||
type.stringRes?.let { context.getString(it) }
|
||||
} else if (role.isNotNullOrBlank() && role.lowercase().contains(type.name.lowercase())) {
|
||||
role
|
||||
} else {
|
||||
listOfNotNull(
|
||||
role?.takeIf { it.isNotNullOrBlank() },
|
||||
type.stringRes?.let { context.getString(it) },
|
||||
).takeIf { it.isNotEmpty() }?.joinToString(" - ")
|
||||
}
|
||||
|
||||
@get:StringRes
|
||||
private val PersonKind.stringRes: Int?
|
||||
get() =
|
||||
when (this) {
|
||||
PersonKind.UNKNOWN -> R.string.unknown
|
||||
PersonKind.ACTOR -> R.string.actor
|
||||
PersonKind.DIRECTOR -> R.string.director
|
||||
PersonKind.COMPOSER -> R.string.composer
|
||||
PersonKind.WRITER -> R.string.writer
|
||||
PersonKind.GUEST_STAR -> R.string.guest_star
|
||||
PersonKind.PRODUCER -> R.string.producer
|
||||
PersonKind.CONDUCTOR -> R.string.conductor
|
||||
PersonKind.LYRICIST -> R.string.lyricist
|
||||
PersonKind.ARRANGER -> R.string.arranger
|
||||
PersonKind.ENGINEER -> R.string.engineer
|
||||
PersonKind.MIXER -> R.string.mixer
|
||||
PersonKind.REMIXER -> R.string.mixer
|
||||
PersonKind.CREATOR -> R.string.creator
|
||||
PersonKind.ARTIST -> R.string.artist
|
||||
PersonKind.ALBUM_ARTIST -> R.string.artist
|
||||
else -> null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
package com.github.damontecres.wholphin.data.model
|
||||
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Entity
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import java.util.UUID
|
||||
|
||||
@Entity(tableName = "playback_effects", primaryKeys = ["jellyfinUserRowId", "itemId", "type"])
|
||||
data class PlaybackEffect(
|
||||
val jellyfinUserRowId: Int,
|
||||
val itemId: UUID,
|
||||
val type: BaseItemKind,
|
||||
@Embedded val videoFilter: VideoFilter,
|
||||
)
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
package com.github.damontecres.wholphin.data.model
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
|
||||
|
|
@ -24,4 +25,5 @@ data class NavDrawerPinnedItem(
|
|||
val userId: Int,
|
||||
val itemId: String,
|
||||
val type: NavPinType,
|
||||
@ColumnInfo(defaultValue = "-1") val order: Int,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,223 @@
|
|||
package com.github.damontecres.wholphin.data.model
|
||||
|
||||
import android.graphics.ColorMatrix
|
||||
import androidx.annotation.IntRange
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.effect.Brightness
|
||||
import androidx.media3.effect.Contrast
|
||||
import androidx.media3.effect.GaussianBlur
|
||||
import androidx.media3.effect.GlEffect
|
||||
import androidx.media3.effect.HslAdjustment
|
||||
import androidx.media3.effect.RgbAdjustment
|
||||
import androidx.media3.effect.ScaleAndRotateTransformation
|
||||
import androidx.room.Ignore
|
||||
|
||||
/**
|
||||
* Modifications to a video playback
|
||||
*/
|
||||
data class VideoFilter(
|
||||
val rotation: Int = 0,
|
||||
@param:IntRange(0, 200) val brightness: Int = COLOR_DEFAULT,
|
||||
@param:IntRange(0, 200) val contrast: Int = COLOR_DEFAULT,
|
||||
@param:IntRange(0, 200) val saturation: Int = COLOR_DEFAULT,
|
||||
@param:IntRange(0, 360) val hue: Int = HUE_DEFAULT,
|
||||
@param:IntRange(0, 200) val red: Int = COLOR_DEFAULT,
|
||||
@param:IntRange(0, 200) val green: Int = COLOR_DEFAULT,
|
||||
@param:IntRange(0, 200) val blue: Int = COLOR_DEFAULT,
|
||||
@param:IntRange(0, 250) val blur: Int = 0,
|
||||
) {
|
||||
@Ignore
|
||||
val colorMatrix: androidx.compose.ui.graphics.ColorMatrix = createComposeColorMatrix()
|
||||
|
||||
companion object {
|
||||
const val COLOR_DEFAULT = 100
|
||||
const val HUE_DEFAULT = 0
|
||||
}
|
||||
|
||||
fun isRotated(): Boolean = rotation != 0 && rotation % 360 != 0
|
||||
|
||||
fun hasRgb(): Boolean = red != COLOR_DEFAULT || green != COLOR_DEFAULT || blue != COLOR_DEFAULT
|
||||
|
||||
fun hasBrightness(): Boolean = brightness != COLOR_DEFAULT
|
||||
|
||||
fun hasContrast(): Boolean = contrast != COLOR_DEFAULT
|
||||
|
||||
fun hasHsl(): Boolean = hue != HUE_DEFAULT || saturation != COLOR_DEFAULT
|
||||
|
||||
fun hasBlur(): Boolean = blur > 0
|
||||
|
||||
fun hasImageFilter(): Boolean = hasRgb() || hasBrightness() || hasContrast() || saturation != COLOR_DEFAULT
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun rgbAdjustment(): RgbAdjustment =
|
||||
RgbAdjustment
|
||||
.Builder()
|
||||
.setRedScale(red / COLOR_DEFAULT.toFloat())
|
||||
.setGreenScale(green / COLOR_DEFAULT.toFloat())
|
||||
.setBlueScale(blue / COLOR_DEFAULT.toFloat())
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Create the list of effects to apply
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
fun createEffectList(): List<GlEffect> =
|
||||
buildList {
|
||||
if (isRotated()) {
|
||||
add(
|
||||
ScaleAndRotateTransformation
|
||||
.Builder()
|
||||
.setRotationDegrees(rotation.toFloat())
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
if (hasRgb()) {
|
||||
add(rgbAdjustment())
|
||||
}
|
||||
if (hasBrightness()) {
|
||||
add(Brightness((brightness - 100) / 100f))
|
||||
}
|
||||
if (hasContrast()) {
|
||||
add(Contrast((contrast - 100) / 100f))
|
||||
}
|
||||
if (hasHsl()) {
|
||||
add(
|
||||
HslAdjustment
|
||||
.Builder()
|
||||
.adjustHue(hue.toFloat())
|
||||
.adjustSaturation((saturation - 100).toFloat())
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
if (hasBlur()) {
|
||||
add(GaussianBlur(blur / 10f))
|
||||
}
|
||||
}
|
||||
|
||||
private fun saturationMatrix(): FloatArray {
|
||||
val rF = 0.2999f
|
||||
val gF = 0.587f
|
||||
val bF = 0.114f
|
||||
val s = saturation / 100.0f
|
||||
|
||||
val ms = 1.0f - s
|
||||
val rT = rF * ms
|
||||
val gT = gF * ms
|
||||
val bT = bF * ms
|
||||
|
||||
val m =
|
||||
FloatArray(20) {
|
||||
when (it) {
|
||||
0 -> (rT + s)
|
||||
1 -> gT
|
||||
2 -> bT
|
||||
5 -> rT
|
||||
6 -> (gT + s)
|
||||
7 -> bT
|
||||
10 -> rT
|
||||
11 -> gT
|
||||
12 -> (bT + s)
|
||||
18 -> 1f
|
||||
else -> 0f
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
fun createColorMatrix(): ColorMatrix {
|
||||
val matrix = ColorMatrix()
|
||||
val tempMatrix = ColorMatrix()
|
||||
|
||||
if (saturation != COLOR_DEFAULT) {
|
||||
matrix.set(saturationMatrix())
|
||||
}
|
||||
if (hasRgb()) {
|
||||
val colorMatrix = rgbAdjustment().getMatrix(0L, false)
|
||||
val m = FloatArray(20)
|
||||
m[0] = colorMatrix[0]
|
||||
m[1] = colorMatrix[1]
|
||||
m[2] = colorMatrix[2]
|
||||
m[3] = colorMatrix[3]
|
||||
m[4] = 0f
|
||||
m[5] = colorMatrix[4]
|
||||
m[6] = colorMatrix[5]
|
||||
m[7] = colorMatrix[6]
|
||||
m[8] = colorMatrix[7]
|
||||
m[9] = 0f
|
||||
m[10] = colorMatrix[8]
|
||||
m[11] = colorMatrix[9]
|
||||
m[12] = colorMatrix[10]
|
||||
m[13] = colorMatrix[11]
|
||||
m[14] = 0f
|
||||
m[15] = colorMatrix[12]
|
||||
m[16] = colorMatrix[13]
|
||||
m[17] = colorMatrix[14]
|
||||
m[18] = colorMatrix[15]
|
||||
m[19] = 0f
|
||||
tempMatrix.set(m)
|
||||
matrix.postConcat(tempMatrix)
|
||||
}
|
||||
if (hasContrast()) {
|
||||
val scale = contrast / 100.0f
|
||||
tempMatrix.setScale(scale, scale, scale, 1f)
|
||||
matrix.postConcat(tempMatrix)
|
||||
}
|
||||
if (hasBrightness()) {
|
||||
val b = brightness / 100.0f
|
||||
val m = FloatArray(20)
|
||||
m[0] = b
|
||||
m[6] = b
|
||||
m[12] = b
|
||||
m[18] = 1f
|
||||
tempMatrix.set(m)
|
||||
matrix.postConcat(tempMatrix)
|
||||
}
|
||||
// TODO hue
|
||||
// TODO blur
|
||||
return matrix
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
fun createComposeColorMatrix(): androidx.compose.ui.graphics.ColorMatrix {
|
||||
val matrix =
|
||||
androidx.compose.ui.graphics
|
||||
.ColorMatrix()
|
||||
|
||||
if (saturation != COLOR_DEFAULT) {
|
||||
matrix.setToSaturation(saturation / 100f)
|
||||
}
|
||||
if (hasRgb()) {
|
||||
matrix.setToScale(
|
||||
redScale = red / 100f,
|
||||
greenScale = green / 100f,
|
||||
blueScale = blue / 100f,
|
||||
alphaScale = 1f,
|
||||
)
|
||||
}
|
||||
if (hasContrast()) {
|
||||
val scale = contrast / 100.0f
|
||||
val tempMatrix =
|
||||
androidx.compose.ui.graphics
|
||||
.ColorMatrix()
|
||||
tempMatrix.setToScale(scale, scale, scale, 1f)
|
||||
matrix.timesAssign(tempMatrix)
|
||||
}
|
||||
if (hasBrightness()) {
|
||||
val b = brightness / 100.0f
|
||||
val m = FloatArray(20)
|
||||
m[0] = b
|
||||
m[6] = b
|
||||
m[12] = b
|
||||
m[18] = 1f
|
||||
val tempMatrix =
|
||||
androidx.compose.ui.graphics
|
||||
.ColorMatrix(m)
|
||||
matrix.timesAssign(tempMatrix)
|
||||
}
|
||||
// TODO hue
|
||||
// TODO blur
|
||||
return matrix
|
||||
}
|
||||
}
|
||||
|
|
@ -186,6 +186,45 @@ sealed interface AppPreference<Pref, T> {
|
|||
summarizer = { value -> value?.toString() },
|
||||
)
|
||||
|
||||
val MaxDaysNextUpOptions = listOf(7, 14, 30, 60, 90, 180, 365)
|
||||
val MaxDaysNextUp =
|
||||
AppSliderPreference<AppPreferences>(
|
||||
title = R.string.max_days_next_up,
|
||||
defaultValue = -1,
|
||||
min = 0,
|
||||
// Max is "no limit" stored as -1
|
||||
max = MaxDaysNextUpOptions.lastIndex + 1L,
|
||||
interval = 1,
|
||||
getter = {
|
||||
MaxDaysNextUpOptions
|
||||
.indexOf(it.homePagePreferences.maxDaysNextUp)
|
||||
.takeIf { it in MaxDaysNextUpOptions.indices }
|
||||
?.toLong()
|
||||
?: MaxDaysNextUpOptions.size.toLong()
|
||||
},
|
||||
setter = { prefs, index ->
|
||||
prefs.updateHomePagePreferences {
|
||||
maxDaysNextUp = MaxDaysNextUpOptions.getOrNull(index.toInt()) ?: -1
|
||||
}
|
||||
},
|
||||
summarizer = { value ->
|
||||
if (value != null) {
|
||||
val v = MaxDaysNextUpOptions.getOrNull(value.toInt()) ?: -1
|
||||
if (v == -1) {
|
||||
WholphinApplication.instance.getString(R.string.no_limit)
|
||||
} else {
|
||||
WholphinApplication.instance.resources.getQuantityString(
|
||||
R.plurals.days,
|
||||
v,
|
||||
v.toString(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
val CombineContinueNext =
|
||||
AppSwitchPreference<AppPreferences>(
|
||||
title = R.string.combine_continue_next,
|
||||
|
|
@ -617,6 +656,13 @@ sealed interface AppPreference<Pref, T> {
|
|||
setter = { prefs, _ -> prefs },
|
||||
)
|
||||
|
||||
val CustomizeHome =
|
||||
AppDestinationPreference<AppPreferences>(
|
||||
title = R.string.customize_home,
|
||||
destination = Destination.HomeSettings,
|
||||
summary = R.string.customize_home_summary,
|
||||
)
|
||||
|
||||
val SendCrashReports =
|
||||
AppSwitchPreference<AppPreferences>(
|
||||
title = R.string.send_crash_reports,
|
||||
|
|
@ -695,6 +741,18 @@ sealed interface AppPreference<Pref, T> {
|
|||
valueToIndex = { it.number },
|
||||
)
|
||||
|
||||
val ManageMedia =
|
||||
AppSwitchPreference<AppPreferences>(
|
||||
title = R.string.show_media_management,
|
||||
defaultValue = false,
|
||||
getter = { it.interfacePreferences.enableMediaManagement },
|
||||
setter = { prefs, value ->
|
||||
prefs.updateInterfacePreferences { enableMediaManagement = value }
|
||||
},
|
||||
summaryOn = R.string.enabled,
|
||||
summaryOff = R.string.disabled,
|
||||
)
|
||||
|
||||
val OneClickPause =
|
||||
AppSwitchPreference<AppPreferences>(
|
||||
title = R.string.one_click_pause,
|
||||
|
|
@ -710,7 +768,7 @@ sealed interface AppPreference<Pref, T> {
|
|||
val SubtitleStyle =
|
||||
AppDestinationPreference<AppPreferences>(
|
||||
title = R.string.subtitle_style,
|
||||
destination = Destination.Settings(PreferenceScreenOption.SUBTITLES),
|
||||
destination = Destination.SubtitleSettings(false),
|
||||
)
|
||||
|
||||
val RefreshRateSwitching =
|
||||
|
|
@ -903,6 +961,60 @@ sealed interface AppPreference<Pref, T> {
|
|||
getter = { },
|
||||
setter = { prefs, _ -> prefs },
|
||||
)
|
||||
|
||||
val QuickConnect =
|
||||
AppClickablePreference<AppPreferences>(
|
||||
title = R.string.quick_connect,
|
||||
summary = R.string.quick_connect_summary,
|
||||
getter = { },
|
||||
setter = { prefs, _ -> prefs },
|
||||
)
|
||||
|
||||
val SlideshowDuration =
|
||||
AppSliderPreference<AppPreferences>(
|
||||
title = R.string.slideshow_duration,
|
||||
defaultValue = 5_000,
|
||||
min = 1_000,
|
||||
max = 30.seconds.inWholeMilliseconds,
|
||||
interval = 250,
|
||||
getter = {
|
||||
it.photoPreferences.slideshowDuration
|
||||
},
|
||||
setter = { prefs, value ->
|
||||
prefs.updatePhotoPreferences {
|
||||
slideshowDuration = value
|
||||
}
|
||||
},
|
||||
summarizer = { value ->
|
||||
if (value != null) {
|
||||
val seconds = value / 1000.0
|
||||
WholphinApplication.instance.resources.getString(
|
||||
R.string.decimal_seconds,
|
||||
seconds,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
val SlideshowPlayVideos =
|
||||
AppSwitchPreference<AppPreferences>(
|
||||
title = R.string.play_videos_during_slideshow,
|
||||
defaultValue = false,
|
||||
getter = { it.photoPreferences.slideshowPlayVideos },
|
||||
setter = { prefs, value ->
|
||||
prefs.updatePhotoPreferences { slideshowPlayVideos = value }
|
||||
},
|
||||
summaryOn = R.string.enabled,
|
||||
summaryOff = R.string.disabled,
|
||||
)
|
||||
|
||||
val ScreensaverSettings =
|
||||
AppDestinationPreference<AppPreferences>(
|
||||
title = R.string.screensaver_settings,
|
||||
destination = Destination.Settings(PreferenceScreenOption.SCREENSAVER),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -913,13 +1025,11 @@ val basicPreferences =
|
|||
preferences =
|
||||
listOf(
|
||||
AppPreference.SignInAuto,
|
||||
AppPreference.HomePageItems,
|
||||
AppPreference.CombineContinueNext,
|
||||
AppPreference.RewatchNextUp,
|
||||
AppPreference.PlayThemeMusic,
|
||||
AppPreference.RememberSelectedTab,
|
||||
AppPreference.SubtitleStyle,
|
||||
AppPreference.ThemeColors,
|
||||
AppPreference.ScreensaverSettings,
|
||||
),
|
||||
),
|
||||
PreferenceGroup(
|
||||
|
|
@ -946,6 +1056,7 @@ val basicPreferences =
|
|||
preferences =
|
||||
listOf(
|
||||
AppPreference.RequireProfilePin,
|
||||
AppPreference.CustomizeHome,
|
||||
AppPreference.UserPinnedNavDrawerItems,
|
||||
),
|
||||
),
|
||||
|
|
@ -969,8 +1080,6 @@ val basicPreferences =
|
|||
),
|
||||
)
|
||||
|
||||
val uiPreferences = listOf<PreferenceGroup>()
|
||||
|
||||
private val ExoPlayerSettings =
|
||||
listOf(
|
||||
AppPreference.FfmpegPreference,
|
||||
|
|
@ -1013,10 +1122,14 @@ val advancedPreferences =
|
|||
preferences =
|
||||
listOf(
|
||||
AppPreference.ShowClock,
|
||||
AppPreference.ManageMedia,
|
||||
AppPreference.CombineContinueNext,
|
||||
// Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418
|
||||
// AppPreference.NavDrawerSwitchOnFocus,
|
||||
AppPreference.ControllerTimeout,
|
||||
AppPreference.BackdropStylePref,
|
||||
AppPreference.SlideshowDuration,
|
||||
AppPreference.SlideshowPlayVideos,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
@ -1088,6 +1201,7 @@ val advancedPreferences =
|
|||
title = R.string.more,
|
||||
preferences =
|
||||
listOf(
|
||||
AppPreference.QuickConnect,
|
||||
AppPreference.SendAppLogs,
|
||||
AppPreference.SendCrashReports,
|
||||
AppPreference.DebugLogging,
|
||||
|
|
@ -1107,6 +1221,24 @@ val liveTvPreferences =
|
|||
AppPreference.LiveTvColorCodePrograms,
|
||||
)
|
||||
|
||||
val screensaverPreferences =
|
||||
listOf(
|
||||
PreferenceGroup(
|
||||
title = R.string.screensaver,
|
||||
preferences =
|
||||
listOf(
|
||||
ScreensaverPreference.Enabled,
|
||||
ScreensaverPreference.StartDelay,
|
||||
ScreensaverPreference.Duration,
|
||||
ScreensaverPreference.ShowClock,
|
||||
ScreensaverPreference.Animate,
|
||||
ScreensaverPreference.MaxAge,
|
||||
ScreensaverPreference.ItemTypes,
|
||||
ScreensaverPreference.Start,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
data class AppSwitchPreference<Pref>(
|
||||
@get:StringRes override val title: Int,
|
||||
override val defaultValue: Boolean,
|
||||
|
|
@ -1161,8 +1293,6 @@ data class AppMultiChoicePreference<Pref, T>(
|
|||
override val getter: (prefs: Pref) -> List<T>,
|
||||
override val setter: (prefs: Pref, value: List<T>) -> Pref,
|
||||
@param:StringRes val summary: Int? = null,
|
||||
val toSharedPrefs: (T) -> String,
|
||||
val fromSharedPrefs: (String) -> T?,
|
||||
) : AppPreference<Pref, List<T>>
|
||||
|
||||
data class AppClickablePreference<Pref>(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import androidx.datastore.core.CorruptionException
|
|||
import androidx.datastore.core.Serializer
|
||||
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
|
||||
import com.google.protobuf.InvalidProtocolBufferException
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import javax.inject.Inject
|
||||
|
|
@ -82,6 +83,7 @@ class AppPreferencesSerializer
|
|||
maxItemsPerRow = AppPreference.HomePageItems.defaultValue.toInt()
|
||||
enableRewatchingNextUp = AppPreference.RewatchNextUp.defaultValue
|
||||
combineContinueNext = AppPreference.CombineContinueNext.defaultValue
|
||||
maxDaysNextUp = AppPreference.MaxDaysNextUp.defaultValue.toInt()
|
||||
}.build()
|
||||
interfacePreferences =
|
||||
InterfacePreferences
|
||||
|
|
@ -100,6 +102,12 @@ class AppPreferencesSerializer
|
|||
.apply {
|
||||
resetSubtitles()
|
||||
}.build()
|
||||
hdrSubtitlesPreferences =
|
||||
SubtitlePreferences
|
||||
.newBuilder()
|
||||
.apply {
|
||||
resetSubtitles()
|
||||
}.build()
|
||||
|
||||
liveTvPreferences =
|
||||
LiveTvPreferences
|
||||
|
|
@ -113,6 +121,20 @@ class AppPreferencesSerializer
|
|||
colorCodePrograms =
|
||||
AppPreference.LiveTvColorCodePrograms.defaultValue
|
||||
}.build()
|
||||
|
||||
screensaverPreference =
|
||||
ScreensaverPreferences
|
||||
.newBuilder()
|
||||
.apply {
|
||||
startDelay = ScreensaverPreference.DEFAULT_START_DELAY
|
||||
duration = ScreensaverPreference.DEFAULT_DURATION
|
||||
animate = ScreensaverPreference.Animate.defaultValue
|
||||
maxAgeFilter = ScreensaverPreference.DEFAULT_MAX_AGE
|
||||
showClock = ScreensaverPreference.ShowClock.defaultValue
|
||||
clearItemTypes()
|
||||
addItemTypes(BaseItemKind.MOVIE.serialName)
|
||||
addItemTypes(BaseItemKind.SERIES.serialName)
|
||||
}.build()
|
||||
}.build()
|
||||
|
||||
advancedPreferences =
|
||||
|
|
@ -122,6 +144,14 @@ class AppPreferencesSerializer
|
|||
imageDiskCacheSizeBytes =
|
||||
AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT
|
||||
}.build()
|
||||
|
||||
photoPreferences =
|
||||
PhotoPreferences
|
||||
.newBuilder()
|
||||
.apply {
|
||||
slideshowDuration = AppPreference.SlideshowDuration.defaultValue
|
||||
slideshowPlayVideos = AppPreference.SlideshowPlayVideos.defaultValue
|
||||
}.build()
|
||||
}.build()
|
||||
|
||||
override suspend fun readFrom(input: InputStream): AppPreferences {
|
||||
|
|
@ -180,6 +210,16 @@ inline fun AppPreferences.updateAdvancedPreferences(block: AdvancedPreferences.B
|
|||
advancedPreferences = advancedPreferences.toBuilder().apply(block).build()
|
||||
}
|
||||
|
||||
inline fun AppPreferences.updatePhotoPreferences(block: PhotoPreferences.Builder.() -> Unit): AppPreferences =
|
||||
update {
|
||||
photoPreferences = photoPreferences.toBuilder().apply(block).build()
|
||||
}
|
||||
|
||||
inline fun AppPreferences.updateScreensaverPreferences(block: ScreensaverPreferences.Builder.() -> Unit): AppPreferences =
|
||||
updateInterfacePreferences {
|
||||
screensaverPreference = screensaverPreference.toBuilder().apply(block).build()
|
||||
}
|
||||
|
||||
fun SubtitlePreferences.Builder.resetSubtitles() {
|
||||
fontSize = SubtitleSettings.FontSize.defaultValue.toInt()
|
||||
fontColor = SubtitleSettings.FontColor.defaultValue.toArgb()
|
||||
|
|
@ -193,4 +233,5 @@ fun SubtitlePreferences.Builder.resetSubtitles() {
|
|||
backgroundStyle = SubtitleSettings.BackgroundStylePref.defaultValue
|
||||
margin = SubtitleSettings.Margin.defaultValue.toInt()
|
||||
edgeThickness = SubtitleSettings.EdgeThickness.defaultValue.toInt()
|
||||
imageSubtitleOpacity = SubtitleSettings.ImageOpacity.defaultValue.toInt()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,183 @@
|
|||
package com.github.damontecres.wholphin.preferences
|
||||
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.WholphinApplication
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
object ScreensaverPreference {
|
||||
val Enabled =
|
||||
AppSwitchPreference<AppPreferences>(
|
||||
title = R.string.in_app_screensaver,
|
||||
defaultValue = false,
|
||||
getter = { it.interfacePreferences.screensaverPreference.enabled },
|
||||
setter = { prefs, value ->
|
||||
prefs.updateScreensaverPreferences { enabled = value }
|
||||
},
|
||||
summaryOn = R.string.yes,
|
||||
summaryOff = R.string.no,
|
||||
)
|
||||
|
||||
const val DEFAULT_START_DELAY = 15 * 60_000L
|
||||
private val startDelayValues =
|
||||
listOf(
|
||||
30_000L,
|
||||
60_000L,
|
||||
2 * 60_000L,
|
||||
5 * 60_000L,
|
||||
10 * 60_000L,
|
||||
15 * 60_000L,
|
||||
30 * 60_000L,
|
||||
60 * 60_000L,
|
||||
)
|
||||
val StartDelay =
|
||||
AppSliderPreference<AppPreferences>(
|
||||
title = R.string.start_after,
|
||||
defaultValue = startDelayValues.indexOf(DEFAULT_START_DELAY).toLong(),
|
||||
min = 0,
|
||||
max = startDelayValues.size - 1L,
|
||||
interval = 1,
|
||||
getter = {
|
||||
startDelayValues.indexOf(it.interfacePreferences.screensaverPreference.startDelay).toLong()
|
||||
},
|
||||
setter = { prefs, value ->
|
||||
prefs.updateScreensaverPreferences {
|
||||
startDelay = startDelayValues[value.toInt()]
|
||||
}
|
||||
},
|
||||
summarizer = { value ->
|
||||
if (value != null) {
|
||||
val v = startDelayValues.getOrNull(value.toInt()) ?: DEFAULT_START_DELAY
|
||||
v.milliseconds.toString()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const val DEFAULT_DURATION = 30_000L
|
||||
private val durationValues =
|
||||
listOf(
|
||||
15_000L,
|
||||
30_000L,
|
||||
60_000L,
|
||||
2 * 60_000L,
|
||||
)
|
||||
val Duration =
|
||||
AppSliderPreference<AppPreferences>(
|
||||
title = R.string.duration,
|
||||
defaultValue = durationValues.indexOf(DEFAULT_DURATION).toLong(),
|
||||
min = 0,
|
||||
max = durationValues.size - 1L,
|
||||
interval = 1,
|
||||
getter = {
|
||||
durationValues.indexOf(it.interfacePreferences.screensaverPreference.duration).toLong()
|
||||
},
|
||||
setter = { prefs, value ->
|
||||
prefs.updateScreensaverPreferences {
|
||||
duration = durationValues[value.toInt()]
|
||||
}
|
||||
},
|
||||
summarizer = { value ->
|
||||
if (value != null) {
|
||||
val v = durationValues.getOrNull(value.toInt()) ?: DEFAULT_DURATION
|
||||
v.milliseconds.toString()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
val ShowClock =
|
||||
AppSwitchPreference<AppPreferences>(
|
||||
title = R.string.show_clock,
|
||||
defaultValue = AppPreference.ShowClock.defaultValue,
|
||||
getter = { it.interfacePreferences.screensaverPreference.showClock },
|
||||
setter = { prefs, value ->
|
||||
prefs.updateScreensaverPreferences { showClock = value }
|
||||
},
|
||||
summaryOn = R.string.yes,
|
||||
summaryOff = R.string.no,
|
||||
)
|
||||
|
||||
val Animate =
|
||||
AppSwitchPreference<AppPreferences>(
|
||||
title = R.string.animate,
|
||||
defaultValue = true,
|
||||
getter = { it.interfacePreferences.screensaverPreference.animate },
|
||||
setter = { prefs, value ->
|
||||
prefs.updateScreensaverPreferences { animate = value }
|
||||
},
|
||||
summaryOn = R.string.enabled,
|
||||
summaryOff = R.string.disabled,
|
||||
)
|
||||
|
||||
const val DEFAULT_MAX_AGE = 16
|
||||
private val maxAgeValues = listOf(0, 5, 10, 13, 14, 16, 18, 21, -1)
|
||||
val MaxAge =
|
||||
AppSliderPreference<AppPreferences>(
|
||||
title = R.string.max_age_rating,
|
||||
defaultValue = maxAgeValues.indexOf(DEFAULT_MAX_AGE).toLong(),
|
||||
min = 0,
|
||||
max = maxAgeValues.size - 1L,
|
||||
interval = 1,
|
||||
getter = {
|
||||
it.interfacePreferences.screensaverPreference.maxAgeFilter
|
||||
.takeIf { it >= 0 }
|
||||
?.let { maxAgeValues.indexOf(it).toLong() }
|
||||
?: maxAgeValues.lastIndex.toLong()
|
||||
},
|
||||
setter = { prefs, value ->
|
||||
prefs.updateScreensaverPreferences {
|
||||
maxAgeFilter = maxAgeValues[value.toInt()]
|
||||
}
|
||||
},
|
||||
summarizer = { value ->
|
||||
when (value) {
|
||||
null -> {
|
||||
null
|
||||
}
|
||||
|
||||
maxAgeValues.lastIndex.toLong() -> {
|
||||
WholphinApplication.instance.getString(R.string.no_max)
|
||||
}
|
||||
|
||||
0L -> {
|
||||
WholphinApplication.instance.getString(R.string.for_all_ages)
|
||||
}
|
||||
|
||||
else -> {
|
||||
WholphinApplication.instance.getString(
|
||||
R.string.up_to_age,
|
||||
maxAgeValues[value.toInt()].toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
val ItemTypes =
|
||||
AppMultiChoicePreference<AppPreferences, BaseItemKind>(
|
||||
title = R.string.include_types,
|
||||
summary = R.string.include_types_summary,
|
||||
defaultValue = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
|
||||
allValues = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES, BaseItemKind.PHOTO),
|
||||
displayValues = R.array.screensaver_item_types,
|
||||
getter = {
|
||||
it.interfacePreferences.screensaverPreference.itemTypesList.map { type ->
|
||||
BaseItemKind.fromName(type)
|
||||
}
|
||||
},
|
||||
setter = { prefs, value ->
|
||||
prefs.updateScreensaverPreferences {
|
||||
clearItemTypes()
|
||||
addAllItemTypes(value.map { it.serialName })
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
val Start =
|
||||
AppClickablePreference<AppPreferences>(
|
||||
title = R.string.start_screensaver,
|
||||
)
|
||||
}
|
||||
|
|
@ -8,17 +8,22 @@ import androidx.preference.PreferenceManager
|
|||
import com.github.damontecres.wholphin.WholphinApplication
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.ScreensaverPreference
|
||||
import com.github.damontecres.wholphin.preferences.update
|
||||
import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences
|
||||
import com.github.damontecres.wholphin.preferences.updateHomePagePreferences
|
||||
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
|
||||
import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences
|
||||
import com.github.damontecres.wholphin.preferences.updateMpvOptions
|
||||
import com.github.damontecres.wholphin.preferences.updatePhotoPreferences
|
||||
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
|
||||
import com.github.damontecres.wholphin.preferences.updateScreensaverPreferences
|
||||
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
|
||||
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
|
||||
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
|
||||
import com.github.damontecres.wholphin.util.Version
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
|
@ -213,4 +218,49 @@ suspend fun upgradeApp(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.4.1-6-g0"))) {
|
||||
appPreferences.updateData {
|
||||
it.updateInterfacePreferences {
|
||||
subtitlesPreferences =
|
||||
subtitlesPreferences
|
||||
.toBuilder()
|
||||
.apply {
|
||||
imageSubtitleOpacity = SubtitleSettings.ImageOpacity.defaultValue.toInt()
|
||||
}.build()
|
||||
// Copy current subtitle prefs as HDR ones
|
||||
hdrSubtitlesPreferences = subtitlesPreferences.toBuilder().build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.4.1-7-g0"))) {
|
||||
appPreferences.updateData {
|
||||
it.updatePhotoPreferences {
|
||||
slideshowDuration = AppPreference.SlideshowDuration.defaultValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.4.1-14-g0"))) {
|
||||
appPreferences.updateData {
|
||||
it.updateHomePagePreferences {
|
||||
maxDaysNextUp = AppPreference.MaxDaysNextUp.defaultValue.toInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.5.0-6-g0"))) {
|
||||
appPreferences.updateData {
|
||||
it.updateScreensaverPreferences {
|
||||
startDelay = ScreensaverPreference.DEFAULT_START_DELAY
|
||||
duration = ScreensaverPreference.DEFAULT_DURATION
|
||||
animate = ScreensaverPreference.Animate.defaultValue
|
||||
maxAgeFilter = ScreensaverPreference.DEFAULT_MAX_AGE
|
||||
clearItemTypes()
|
||||
addItemTypes(BaseItemKind.MOVIE.serialName)
|
||||
addItemTypes(BaseItemKind.SERIES.serialName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
|
@ -47,7 +48,12 @@ class BackdropService
|
|||
|
||||
suspend fun submit(item: BaseItem) =
|
||||
withContext(Dispatchers.IO) {
|
||||
val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)!!
|
||||
val imageUrl =
|
||||
if (item.type == BaseItemKind.GENRE) {
|
||||
item.imageUrlOverride
|
||||
} else {
|
||||
imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
|
||||
}
|
||||
submit(item.id.toString(), imageUrl)
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -28,15 +28,16 @@ class ImageUrlService
|
|||
itemType: BaseItemKind,
|
||||
seriesId: UUID?,
|
||||
useSeriesForPrimary: Boolean,
|
||||
imageTags: Map<ImageType, String?>,
|
||||
imageType: ImageType,
|
||||
imageTags: Map<ImageType, String?>,
|
||||
backdropTags: List<String>,
|
||||
parentThumbId: UUID? = null,
|
||||
parentBackdropId: UUID? = null,
|
||||
fillWidth: Int? = null,
|
||||
fillHeight: Int? = null,
|
||||
): String? =
|
||||
when (imageType) {
|
||||
ImageType.BACKDROP,
|
||||
ImageType.LOGO,
|
||||
-> {
|
||||
ImageType.LOGO -> {
|
||||
if (seriesId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)) {
|
||||
getItemImageUrl(
|
||||
itemId = seriesId,
|
||||
|
|
@ -54,8 +55,86 @@ class ImageUrlService
|
|||
}
|
||||
}
|
||||
|
||||
ImageType.BACKDROP,
|
||||
-> {
|
||||
if (seriesId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)) {
|
||||
getItemImageUrl(
|
||||
itemId = seriesId,
|
||||
imageType = imageType,
|
||||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
)
|
||||
} else if (backdropTags.isNotEmpty()) {
|
||||
getItemImageUrl(
|
||||
itemId = itemId,
|
||||
imageType = imageType,
|
||||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
ImageType.THUMB -> {
|
||||
if (useSeriesForPrimary && parentThumbId != null &&
|
||||
(itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)
|
||||
) {
|
||||
// Use parent's thumb
|
||||
getItemImageUrl(
|
||||
itemId = parentThumbId,
|
||||
imageType = imageType,
|
||||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
)
|
||||
} else if (useSeriesForPrimary && parentBackdropId != null &&
|
||||
(itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)
|
||||
) {
|
||||
// No parent thumb, so use backdrop instead
|
||||
getItemImageUrl(
|
||||
itemId = parentBackdropId,
|
||||
imageType = ImageType.BACKDROP,
|
||||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
)
|
||||
} else if (parentThumbId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) {
|
||||
getItemImageUrl(
|
||||
itemId = parentThumbId,
|
||||
imageType = imageType,
|
||||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
)
|
||||
} else if (useSeriesForPrimary &&
|
||||
parentThumbId == null &&
|
||||
itemType == BaseItemKind.EPISODE &&
|
||||
imageType !in imageTags
|
||||
) {
|
||||
// Workaround to fall back to episode image if no parent thumb
|
||||
getItemImageUrl(
|
||||
itemId = itemId,
|
||||
imageType = ImageType.PRIMARY,
|
||||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
)
|
||||
} else if (imageType !in imageTags && backdropTags.isNotEmpty()) {
|
||||
// If no thumb, use backdrop if available
|
||||
getItemImageUrl(
|
||||
itemId = itemId,
|
||||
imageType = ImageType.BACKDROP,
|
||||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
)
|
||||
} else {
|
||||
getItemImageUrl(
|
||||
itemId = itemId,
|
||||
imageType = imageType,
|
||||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ImageType.PRIMARY,
|
||||
ImageType.THUMB,
|
||||
ImageType.BANNER,
|
||||
-> {
|
||||
if (useSeriesForPrimary && seriesId != null &&
|
||||
|
|
@ -99,15 +178,19 @@ class ImageUrlService
|
|||
imageType: ImageType,
|
||||
fillWidth: Int? = null,
|
||||
fillHeight: Int? = null,
|
||||
useSeriesForPrimary: Boolean? = null,
|
||||
): String? =
|
||||
if (item != null) {
|
||||
getItemImageUrl(
|
||||
itemId = item.id,
|
||||
itemType = item.type,
|
||||
seriesId = item.data.seriesId,
|
||||
useSeriesForPrimary = item.useSeriesForPrimary,
|
||||
useSeriesForPrimary = useSeriesForPrimary ?: item.useSeriesForPrimary,
|
||||
imageTags = item.data.imageTags.orEmpty(),
|
||||
imageType = imageType,
|
||||
parentThumbId = item.data.parentThumbItemId,
|
||||
parentBackdropId = item.data.parentBackdropItemId,
|
||||
backdropTags = item.data.backdropImageTags.orEmpty(),
|
||||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ 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
|
||||
|
|
@ -21,6 +19,8 @@ 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.CollectionType
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.UserDto
|
||||
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
||||
|
|
@ -44,6 +44,7 @@ class LatestNextUpService
|
|||
userId: UUID,
|
||||
limit: Int,
|
||||
includeEpisodes: Boolean,
|
||||
useSeriesForPrimary: Boolean = true,
|
||||
): List<BaseItem> {
|
||||
val request =
|
||||
GetResumeItemsRequest(
|
||||
|
|
@ -60,13 +61,19 @@ class LatestNextUpService
|
|||
remove(BaseItemKind.EPISODE)
|
||||
}
|
||||
},
|
||||
enableImageTypes =
|
||||
listOf(
|
||||
ImageType.PRIMARY,
|
||||
ImageType.THUMB,
|
||||
ImageType.BACKDROP,
|
||||
),
|
||||
)
|
||||
val items =
|
||||
api.itemsApi
|
||||
.getResumeItems(request)
|
||||
.content
|
||||
.items
|
||||
.map { BaseItem.from(it, api, true) }
|
||||
.map { BaseItem.from(it, api, useSeriesForPrimary) }
|
||||
return items
|
||||
}
|
||||
|
||||
|
|
@ -75,7 +82,11 @@ class LatestNextUpService
|
|||
limit: Int,
|
||||
enableRewatching: Boolean,
|
||||
enableResumable: Boolean,
|
||||
maxDays: Int,
|
||||
useSeriesForPrimary: Boolean = true,
|
||||
): List<BaseItem> {
|
||||
val nextUpDateCutoff =
|
||||
maxDays.takeIf { it > 0 }?.let { LocalDateTime.now().minusDays(it.toLong()) }
|
||||
val request =
|
||||
GetNextUpRequest(
|
||||
userId = userId,
|
||||
|
|
@ -86,13 +97,14 @@ class LatestNextUpService
|
|||
enableResumable = enableResumable,
|
||||
enableUserData = true,
|
||||
enableRewatching = enableRewatching,
|
||||
nextUpDateCutoff = nextUpDateCutoff,
|
||||
)
|
||||
val nextUp =
|
||||
api.tvShowsApi
|
||||
.getNextUp(request)
|
||||
.content
|
||||
.items
|
||||
.map { BaseItem.from(it, api, true) }
|
||||
.map { BaseItem.from(it, api, useSeriesForPrimary) }
|
||||
return nextUp
|
||||
}
|
||||
|
||||
|
|
@ -188,3 +200,17 @@ class LatestNextUpService
|
|||
return@withContext result
|
||||
}
|
||||
}
|
||||
|
||||
val supportedLatestCollectionTypes =
|
||||
setOf(
|
||||
CollectionType.MOVIES,
|
||||
CollectionType.TVSHOWS,
|
||||
CollectionType.HOMEVIDEOS,
|
||||
// Exclude Live TV because a recording folder view will be used instead
|
||||
null, // Recordings & mixed collection types
|
||||
)
|
||||
|
||||
data class LatestData(
|
||||
val title: String,
|
||||
val request: GetLatestMediaRequest,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,110 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.libraryApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class MediaManagementService
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val api: ApiClient,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val userPreferencesService: UserPreferencesService,
|
||||
) {
|
||||
private val _deletedItemFlow =
|
||||
MutableSharedFlow<DeletedItem>(
|
||||
replay = 1,
|
||||
extraBufferCapacity = 0,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
/**
|
||||
* Listen for recently deleted items. Useful for ViewModels to react and refresh data
|
||||
*/
|
||||
val deletedItemFlow: SharedFlow<DeletedItem> = _deletedItemFlow
|
||||
|
||||
suspend fun canDelete(item: BaseItem): Boolean {
|
||||
val appPreferences = userPreferencesService.getCurrent().appPreferences
|
||||
return canDelete(item, appPreferences)
|
||||
}
|
||||
|
||||
fun canDelete(
|
||||
item: BaseItem,
|
||||
appPreferences: AppPreferences,
|
||||
): Boolean {
|
||||
Timber.v("canDelete %s: %s", item.id, item.canDelete)
|
||||
val enabled = appPreferences.interfacePreferences.enableMediaManagement
|
||||
return enabled &&
|
||||
item.canDelete &&
|
||||
if (item.type == BaseItemKind.RECORDING) {
|
||||
serverRepository.currentUserDto.value
|
||||
?.policy
|
||||
?.enableLiveTvManagement == true
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteItem(item: BaseItem): DeleteResult {
|
||||
try {
|
||||
Timber.i("Deleting %s", item.id)
|
||||
// TODO enable
|
||||
api.libraryApi.deleteItem(item.id)
|
||||
_deletedItemFlow.emit(DeletedItem(item))
|
||||
return DeleteResult.Success
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error deleting %s", item.id)
|
||||
return DeleteResult.Error(ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class DeletedItem(
|
||||
val item: BaseItem,
|
||||
)
|
||||
|
||||
sealed interface DeleteResult {
|
||||
data object Success : DeleteResult
|
||||
|
||||
data class Error(
|
||||
val ex: Exception,
|
||||
) : DeleteResult
|
||||
}
|
||||
|
||||
fun ViewModel.deleteItem(
|
||||
context: Context,
|
||||
mediaManagementService: MediaManagementService,
|
||||
item: BaseItem,
|
||||
onSuccess: () -> Unit = {},
|
||||
) = viewModelScope.launchIO {
|
||||
when (val r = mediaManagementService.deleteItem(item)) {
|
||||
is DeleteResult.Error -> {
|
||||
showToast(
|
||||
context,
|
||||
"Error deleting item: ${r.ex.localizedMessage}",
|
||||
)
|
||||
}
|
||||
|
||||
DeleteResult.Success -> {
|
||||
showToast(context, "Deleted")
|
||||
onSuccess.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.clientLogApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.model.ClientInfo
|
||||
import org.jellyfin.sdk.model.DeviceInfo
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class MediaReportService
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val api: ApiClient,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val userPreferencesService: UserPreferencesService,
|
||||
private val clientInfo: ClientInfo,
|
||||
private val deviceInfo: DeviceInfo,
|
||||
private val deviceProfileService: DeviceProfileService,
|
||||
@param:IoCoroutineScope private val ioScope: CoroutineScope,
|
||||
) {
|
||||
val json =
|
||||
Json {
|
||||
encodeDefaults = false
|
||||
}
|
||||
|
||||
fun sendReportFor(itemId: UUID) {
|
||||
ioScope.launchIO(ExceptionHandler(autoToast = true)) {
|
||||
val item = api.userLibraryApi.getItem(itemId = itemId).content
|
||||
sendReportFor(item)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendReportFor(item: BaseItemDto) {
|
||||
val sources =
|
||||
item.mediaSources ?: api.userLibraryApi
|
||||
.getItem(itemId = item.id)
|
||||
.content.mediaSources
|
||||
val sourcesJson = json.encodeToString(sources)
|
||||
val playbackPrefs = userPreferencesService.getCurrent().appPreferences.playbackPreferences
|
||||
val serverVersion = serverRepository.currentServer.value?.serverVersion
|
||||
val deviceProfile =
|
||||
deviceProfileService.getOrCreateDeviceProfile(playbackPrefs, serverVersion)
|
||||
val deviceProfileJson = json.encodeToString(deviceProfile)
|
||||
val body =
|
||||
"""
|
||||
Send media info
|
||||
serverVersion=$serverVersion
|
||||
clientInfo=$clientInfo
|
||||
deviceInfo=$deviceInfo
|
||||
manufacturer=${Build.MANUFACTURER}
|
||||
model=${Build.MODEL}
|
||||
apiLevel=${Build.VERSION.SDK_INT}
|
||||
|
||||
playbackPrefs=${playbackPrefs.toString().replace("\n", ", ").replace("\t", " ")}
|
||||
|
||||
deviceProfile=$deviceProfileJson
|
||||
|
||||
mediaSources=$sourcesJson
|
||||
""".trimIndent()
|
||||
Timber.w(body)
|
||||
val response by api.clientLogApi.logFile(body)
|
||||
showToast(context, "Sent! Filename=${response.fileName}")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.asFlow
|
||||
import com.github.damontecres.wholphin.data.ServerPreferencesDao
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.data.model.NavPinType
|
||||
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
|
||||
import com.github.damontecres.wholphin.ui.main.settings.Library
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
|
||||
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.util.supportedCollectionTypes
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.api.UserDto
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class NavDrawerService
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
@param:DefaultCoroutineScope private val coroutineScope: CoroutineScope,
|
||||
private val api: ApiClient,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val serverPreferencesDao: ServerPreferencesDao,
|
||||
private val seerrServerRepository: SeerrServerRepository,
|
||||
) {
|
||||
private val _state = MutableStateFlow(NavDrawerItemState.EMPTY)
|
||||
val state: StateFlow<NavDrawerItemState> = _state
|
||||
|
||||
init {
|
||||
serverRepository.currentUser
|
||||
.asFlow()
|
||||
.combine(serverRepository.currentUserDto.asFlow()) { user, userDto ->
|
||||
Pair(user, userDto)
|
||||
}.onEach { (user, userDto) ->
|
||||
Timber.d("User updated: user=%s, userDto=%s", user?.id, userDto?.id)
|
||||
_state.update {
|
||||
it.copy(
|
||||
items = emptyList(),
|
||||
moreItems = emptyList(),
|
||||
)
|
||||
}
|
||||
if (user != null && userDto != null && user.id == userDto.id) {
|
||||
updateNavDrawer(user, userDto)
|
||||
}
|
||||
}.catch { ex ->
|
||||
Timber.e(ex, "Error updating nav drawer")
|
||||
showToast(context, "Error fetching user's views")
|
||||
}.launchIn(coroutineScope)
|
||||
|
||||
seerrServerRepository.active
|
||||
.onEach { discoverActive ->
|
||||
_state.update { it.copy(discoverEnabled = discoverActive) }
|
||||
}.launchIn(coroutineScope)
|
||||
}
|
||||
|
||||
suspend fun getAllUserLibraries(
|
||||
userId: UUID,
|
||||
tvAccess: Boolean,
|
||||
): List<Library> {
|
||||
val userViews =
|
||||
api.userViewsApi
|
||||
.getUserViews(userId = userId)
|
||||
.content.items
|
||||
val recordingFolders =
|
||||
if (tvAccess) {
|
||||
api.liveTvApi
|
||||
.getRecordingFolders(userId = userId)
|
||||
.content.items
|
||||
.map { it.id }
|
||||
.toSet()
|
||||
} else {
|
||||
setOf()
|
||||
}
|
||||
val libraries =
|
||||
userViews
|
||||
.filter { it.collectionType in supportedCollectionTypes || it.id in recordingFolders }
|
||||
.map {
|
||||
Library(
|
||||
itemId = it.id,
|
||||
name = it.name ?: "",
|
||||
type = it.type,
|
||||
collectionType = it.collectionType ?: CollectionType.UNKNOWN,
|
||||
isRecordingFolder = it.id in recordingFolders,
|
||||
)
|
||||
}
|
||||
return libraries
|
||||
}
|
||||
|
||||
suspend fun getFilteredUserLibraries(
|
||||
user: JellyfinUser,
|
||||
tvAccess: Boolean,
|
||||
): List<Library> {
|
||||
val pins =
|
||||
serverPreferencesDao
|
||||
.getNavDrawerPinnedItems(user)
|
||||
.associateBy { it.itemId }
|
||||
val libraries =
|
||||
getAllUserLibraries(user.id, tvAccess)
|
||||
.filterNot { pins[ServerNavDrawerItem.getId(it.itemId)]?.type == NavPinType.UNPINNED }
|
||||
return libraries
|
||||
}
|
||||
|
||||
suspend fun updateNavDrawer(
|
||||
user: JellyfinUser,
|
||||
userDto: UserDto,
|
||||
) {
|
||||
val builtins = listOf(NavDrawerItem.Favorites, NavDrawerItem.Discover)
|
||||
val allLibraries = getAllUserLibraries(user.id, userDto.tvAccess)
|
||||
val libraries =
|
||||
allLibraries
|
||||
.map {
|
||||
val destination =
|
||||
if (it.isRecordingFolder) {
|
||||
Destination.Recordings(it.itemId)
|
||||
} else {
|
||||
Destination.MediaItem(
|
||||
it.itemId,
|
||||
it.type,
|
||||
it.collectionType,
|
||||
)
|
||||
}
|
||||
ServerNavDrawerItem(
|
||||
itemId = it.itemId,
|
||||
name = it.name,
|
||||
destination = destination,
|
||||
type = it.collectionType,
|
||||
)
|
||||
}
|
||||
val allItems = builtins + libraries
|
||||
|
||||
val navDrawerPins =
|
||||
withContext(Dispatchers.IO) {
|
||||
serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId }
|
||||
}
|
||||
|
||||
val items = mutableListOf<NavDrawerItem>()
|
||||
val moreItems = mutableListOf<NavDrawerItem>()
|
||||
allItems
|
||||
// Sort by order if non-default, existing items before customize will have -1 value
|
||||
// New items from the server will get Int.MAX_VALUE
|
||||
// Items the user doesn't have access to anymore will be skipped
|
||||
.sortedBy { navDrawerPins[it.id]?.order?.takeIf { it >= 0 } ?: Int.MAX_VALUE }
|
||||
.forEach {
|
||||
// Assume pinned if unknown
|
||||
val pinned = navDrawerPins[it.id]?.type ?: NavPinType.PINNED
|
||||
if (pinned == NavPinType.PINNED) {
|
||||
items.add(it)
|
||||
} else {
|
||||
moreItems.add(it)
|
||||
}
|
||||
}
|
||||
|
||||
_state.update {
|
||||
it.copy(
|
||||
items = items,
|
||||
moreItems = moreItems,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class NavDrawerItemState(
|
||||
val items: List<NavDrawerItem>,
|
||||
val moreItems: List<NavDrawerItem>,
|
||||
val discoverEnabled: Boolean,
|
||||
) {
|
||||
companion object {
|
||||
val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false)
|
||||
}
|
||||
}
|
||||
|
||||
val UserDto.tvAccess: Boolean get() = policy?.enableLiveTvAccess == true
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.Person
|
||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||
import javax.inject.Inject
|
||||
|
|
@ -12,6 +14,7 @@ import javax.inject.Singleton
|
|||
class PeopleFavorites
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val api: ApiClient,
|
||||
) {
|
||||
suspend fun getPeopleFor(item: BaseItem): List<Person> =
|
||||
|
|
@ -27,7 +30,14 @@ class PeopleFavorites
|
|||
val people =
|
||||
item.data.people
|
||||
?.letNotEmpty { people ->
|
||||
people.map { Person.fromDto(it, favorites[it.id] ?: false, api) }
|
||||
people.map {
|
||||
Person.fromDto(
|
||||
context,
|
||||
it,
|
||||
favorites[it.id] ?: false,
|
||||
api,
|
||||
)
|
||||
}
|
||||
}.orEmpty()
|
||||
people
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,10 @@ class PlaybackLifecycleObserver
|
|||
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
val lastDest = navigationManager.backStack.lastOrNull()
|
||||
if (lastDest is Destination.Playback || lastDest is Destination.PlaybackList) {
|
||||
if (lastDest is Destination.Playback ||
|
||||
lastDest is Destination.PlaybackList ||
|
||||
lastDest is Destination.Slideshow
|
||||
) {
|
||||
navigationManager.goBack()
|
||||
}
|
||||
wasPlaying = null
|
||||
|
|
|
|||
|
|
@ -6,17 +6,17 @@ import android.os.Build
|
|||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.Display
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.MainActivity
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.util.EqualityMutableLiveData
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.jellyfin.sdk.model.api.MediaStream
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.math.roundToInt
|
||||
|
|
@ -28,25 +28,6 @@ class RefreshRateService
|
|||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
) {
|
||||
private val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
|
||||
private val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
|
||||
private val originalMode = display.mode
|
||||
|
||||
val supportedDisplayModes get() = display.supportedModes.orEmpty()
|
||||
|
||||
private val displayModes: List<DisplayMode> by lazy {
|
||||
display.supportedModes
|
||||
.orEmpty()
|
||||
.map { DisplayMode(it) }
|
||||
.sortedWith(
|
||||
compareByDescending<DisplayMode>({ it.physicalWidth * it.physicalHeight })
|
||||
.thenBy { it.refreshRateRounded },
|
||||
)
|
||||
}
|
||||
|
||||
private val _refreshRateMode = EqualityMutableLiveData<Int>(originalMode.modeId)
|
||||
val refreshRateMode: LiveData<Int> = _refreshRateMode
|
||||
|
||||
/**
|
||||
* Find the best display mode for the given stream and signal to change to it
|
||||
*/
|
||||
|
|
@ -54,11 +35,23 @@ class RefreshRateService
|
|||
stream: MediaStream,
|
||||
switchRefreshRate: Boolean,
|
||||
switchResolution: Boolean,
|
||||
) {
|
||||
) = withContext(Dispatchers.IO) {
|
||||
if (!switchRefreshRate && !switchResolution) {
|
||||
Timber.v("Not switching either refresh rate nor resolution")
|
||||
return
|
||||
return@withContext
|
||||
}
|
||||
val displayManager =
|
||||
MainActivity.instance.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
|
||||
val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
|
||||
val displayModes =
|
||||
display.supportedModes
|
||||
.orEmpty()
|
||||
.map { DisplayMode(it) }
|
||||
.sortedWith(
|
||||
compareByDescending<DisplayMode>({ it.physicalWidth * it.physicalHeight })
|
||||
.thenBy { it.refreshRateRounded },
|
||||
)
|
||||
|
||||
val currentDisplayMode = display.mode
|
||||
require(stream.type == MediaStreamType.VIDEO) { "Stream is not video" }
|
||||
val width = stream.width
|
||||
|
|
@ -67,7 +60,7 @@ class RefreshRateService
|
|||
if (switchRefreshRate) stream.realFrameRate else currentDisplayMode.refreshRate
|
||||
if (width == null || height == null || frameRate == null) {
|
||||
Timber.w("Video stream missing required info: width=%s, height=%s, frameRate=%s", width, height, frameRate)
|
||||
return
|
||||
return@withContext
|
||||
}
|
||||
Timber.d("Getting refresh rate for: width=%s, height=%s, frameRate=%s", width, height, frameRate)
|
||||
val targetMode =
|
||||
|
|
@ -86,14 +79,20 @@ class RefreshRateService
|
|||
listener,
|
||||
Handler(Looper.myLooper() ?: Looper.getMainLooper()),
|
||||
)
|
||||
_refreshRateMode.setValueOnMain(targetMode.modeId)
|
||||
try {
|
||||
if (!listener.latch.await(5, TimeUnit.SECONDS)) {
|
||||
MainActivity.instance.changeDisplayMode(targetMode.modeId)
|
||||
val result =
|
||||
withTimeoutOrNull(5.seconds) {
|
||||
listener.deferred.await()
|
||||
}
|
||||
if (result == null) {
|
||||
Timber.w("Timed out waiting for display change")
|
||||
showToast(context, "Refresh rate switch is taking a long time")
|
||||
}
|
||||
} catch (ex: InterruptedException) {
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(ex, "Exception waiting for refresh rate switch")
|
||||
} finally {
|
||||
displayManager.unregisterDisplayListener(listener)
|
||||
}
|
||||
val targetRate = (targetMode.refreshRate * 1000).roundToInt()
|
||||
val isSeamless =
|
||||
|
|
@ -110,7 +109,6 @@ class RefreshRateService
|
|||
// Wait the recommended 2 seconds (https://developer.android.com/media/optimize/performance/frame-rate)
|
||||
delay(2.seconds)
|
||||
}
|
||||
displayManager.unregisterDisplayListener(listener)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -118,13 +116,13 @@ class RefreshRateService
|
|||
* Reset the display mode to the original
|
||||
*/
|
||||
fun resetRefreshRate() {
|
||||
_refreshRateMode.value = originalMode.modeId
|
||||
MainActivity.instance.changeDisplayMode(0)
|
||||
}
|
||||
|
||||
private class Listener(
|
||||
val displayId: Int,
|
||||
) : DisplayManager.DisplayListener {
|
||||
val latch = CountDownLatch(1)
|
||||
val deferred = CompletableDeferred<Unit>()
|
||||
|
||||
override fun onDisplayAdded(displayId: Int) {
|
||||
}
|
||||
|
|
@ -132,7 +130,7 @@ class RefreshRateService
|
|||
override fun onDisplayChanged(displayId: Int) {
|
||||
if (displayId == this.displayId) {
|
||||
Timber.v("Got display change for $displayId")
|
||||
latch.countDown()
|
||||
deferred.complete(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -160,33 +158,58 @@ class RefreshRateService
|
|||
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
|
||||
}
|
||||
.filter { frameRateMatches(it.refreshRateRounded, streamRate) }
|
||||
} 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 }
|
||||
candidates
|
||||
.filter { it.refreshRateRounded == streamRate }
|
||||
.maxByOrNull { it.physicalWidth * it.physicalHeight }
|
||||
?: candidates.maxByOrNull { it.physicalWidth * it.physicalHeight }
|
||||
} else {
|
||||
// Exact resolution & frame rate
|
||||
candidates.firstOrNull {
|
||||
it.physicalWidth == streamWidth &&
|
||||
it.physicalHeight == streamHeight &&
|
||||
it.refreshRateRounded == streamRate
|
||||
}
|
||||
?: candidates.firstOrNull {
|
||||
it.physicalWidth == streamWidth &&
|
||||
// Next highest resolution, exact frame rate
|
||||
?: candidates.lastOrNull {
|
||||
it.physicalWidth >= streamWidth &&
|
||||
it.physicalHeight >= streamHeight &&
|
||||
it.refreshRateRounded == streamRate
|
||||
}
|
||||
// Exact resolution, acceptable frame rate
|
||||
?: candidates.lastOrNull {
|
||||
it.physicalWidth == streamWidth &&
|
||||
it.physicalHeight == streamHeight &&
|
||||
frameRateMatches(it.refreshRateRounded, streamRate)
|
||||
}
|
||||
// Next highest resolution, acceptable frame rate
|
||||
?: candidates.lastOrNull {
|
||||
it.physicalWidth >= streamWidth &&
|
||||
it.physicalHeight >= streamHeight &&
|
||||
frameRateMatches(it.refreshRateRounded, streamRate)
|
||||
}
|
||||
// Fall back to largest resolution, exact frame rate
|
||||
?: candidates
|
||||
.filter { it.refreshRateRounded == streamRate }
|
||||
.maxByOrNull { it.physicalWidth * it.physicalHeight }
|
||||
// Finally, just the highest resolution
|
||||
?: candidates.maxByOrNull { it.physicalWidth * it.physicalHeight }
|
||||
}
|
||||
}
|
||||
|
||||
fun frameRateMatches(
|
||||
refreshRateRounded: Int,
|
||||
streamRate: Int,
|
||||
): Boolean {
|
||||
return refreshRateRounded % streamRate == 0 || // Exact multiple
|
||||
refreshRateRounded == (streamRate * 2.5).roundToInt() // eg 24 & 60fps
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,218 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import coil3.imageLoader
|
||||
import coil3.request.ImageRequest
|
||||
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
|
||||
import com.github.damontecres.wholphin.ui.components.CurrentItem
|
||||
import com.github.damontecres.wholphin.ui.formatDate
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.cancellable
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.libraryApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Singleton
|
||||
class ScreensaverService
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
@param:DefaultCoroutineScope private val scope: CoroutineScope,
|
||||
private val api: ApiClient,
|
||||
private val userPreferencesService: UserPreferencesService,
|
||||
private val imageUrlService: ImageUrlService,
|
||||
) {
|
||||
private val _state = MutableStateFlow(ScreensaverState(false, false, false, false))
|
||||
val state: StateFlow<ScreensaverState> = _state
|
||||
|
||||
val keepScreenOn = MutableStateFlow(false)
|
||||
|
||||
private var waitJob: Job? = null
|
||||
|
||||
init {
|
||||
userPreferencesService.flow
|
||||
.onEach { prefs ->
|
||||
_state.update {
|
||||
val enabled =
|
||||
prefs.appPreferences.interfacePreferences.screensaverPreference.enabled
|
||||
keepScreenOnInternal(enabled)
|
||||
ScreensaverState(enabled, false, false, false)
|
||||
}
|
||||
}.launchIn(scope)
|
||||
}
|
||||
|
||||
fun pulse() {
|
||||
waitJob?.cancel()
|
||||
if (_state.value.enabled) {
|
||||
// Timber.v("pulse")
|
||||
_state.update {
|
||||
if (!it.active) {
|
||||
it.copy(active = false)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
|
||||
if (!_state.value.paused) {
|
||||
waitJob =
|
||||
scope.launch(ExceptionHandler()) {
|
||||
val startDelay =
|
||||
userPreferencesService
|
||||
.getCurrent()
|
||||
.appPreferences.interfacePreferences.screensaverPreference.startDelay.milliseconds
|
||||
delay(startDelay)
|
||||
_state.update {
|
||||
it.copy(active = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun start() {
|
||||
_state.update {
|
||||
it.copy(
|
||||
enabledTemp = true,
|
||||
active = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(cancelJob: Boolean) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
enabledTemp = false,
|
||||
active = false,
|
||||
)
|
||||
}
|
||||
if (cancelJob) waitJob?.cancel()
|
||||
}
|
||||
|
||||
fun keepScreenOn(keep: Boolean) {
|
||||
scope.launchDefault {
|
||||
val screensaverEnabled = _state.value.enabled
|
||||
Timber.d("Keep screen on: %s, screensaverEnabled=%s", keep, screensaverEnabled)
|
||||
if (screensaverEnabled) {
|
||||
// Page is requesting to keep screen on, so we don't wait to show the screensaver
|
||||
_state.update {
|
||||
it.copy(active = false, paused = keep)
|
||||
}
|
||||
if (!keep) {
|
||||
pulse()
|
||||
}
|
||||
} else {
|
||||
keepScreenOnInternal(keep)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun keepScreenOnInternal(keep: Boolean) {
|
||||
keepScreenOn.update { keep }
|
||||
}
|
||||
|
||||
fun createItemFlow(scope: CoroutineScope): Flow<CurrentItem?> =
|
||||
flow {
|
||||
val prefs =
|
||||
userPreferencesService.flow
|
||||
.first()
|
||||
.appPreferences
|
||||
.interfacePreferences.screensaverPreference
|
||||
val maxAge = prefs.maxAgeFilter.takeIf { it >= 0 }
|
||||
val itemTypes = prefs.itemTypesList.map { BaseItemKind.fromName(it) }
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
recursive = true,
|
||||
includeItemTypes = itemTypes,
|
||||
imageTypes = if (BaseItemKind.PHOTO in itemTypes) null else listOf(ImageType.BACKDROP),
|
||||
sortBy = listOf(ItemSortBy.RANDOM),
|
||||
maxOfficialRating = maxAge?.toString(),
|
||||
hasParentalRating = maxAge?.let { true },
|
||||
)
|
||||
val pager =
|
||||
ApiRequestPager(api, request, GetItemsRequestHandler, scope).init()
|
||||
Timber.v("Got %s items", pager.size)
|
||||
var index = 0
|
||||
if (pager.isEmpty()) {
|
||||
emit(null)
|
||||
} else {
|
||||
while (true) {
|
||||
val item = pager.getBlocking(index)
|
||||
Timber.v("Next index=%s, item=%s", index, item?.id)
|
||||
if (item != null) {
|
||||
val backdropUrl =
|
||||
if (item.type == BaseItemKind.PHOTO) {
|
||||
api.libraryApi.getDownloadUrl(item.id)
|
||||
} else {
|
||||
imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
|
||||
}
|
||||
val title =
|
||||
if (item.type == BaseItemKind.PHOTO) {
|
||||
item.data.premiereDate?.let {
|
||||
formatDate(it.toLocalDate())
|
||||
}
|
||||
} else {
|
||||
item.title
|
||||
}
|
||||
val logoUrl = imageUrlService.getItemImageUrl(item, ImageType.LOGO)
|
||||
if (backdropUrl != null) {
|
||||
try {
|
||||
context.imageLoader
|
||||
.enqueue(
|
||||
ImageRequest
|
||||
.Builder(context)
|
||||
.data(backdropUrl)
|
||||
.build(),
|
||||
).job
|
||||
.await()
|
||||
emit(CurrentItem(item, backdropUrl, logoUrl, title ?: ""))
|
||||
} catch (_: CancellationException) {
|
||||
break
|
||||
}
|
||||
val duration =
|
||||
userPreferencesService
|
||||
.getCurrent()
|
||||
.appPreferences
|
||||
.interfacePreferences.screensaverPreference.duration.milliseconds
|
||||
delay(duration)
|
||||
}
|
||||
}
|
||||
index++
|
||||
}
|
||||
}
|
||||
}.flowOn(Dispatchers.Default).cancellable()
|
||||
}
|
||||
|
||||
data class ScreensaverState(
|
||||
val enabled: Boolean,
|
||||
val enabledTemp: Boolean,
|
||||
val active: Boolean,
|
||||
val paused: Boolean,
|
||||
) {
|
||||
val show get() = (enabled || enabledTemp) && active && !paused
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ 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.JellyfinUser
|
||||
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
|
||||
import com.github.damontecres.wholphin.data.model.SeerrPermission
|
||||
import com.github.damontecres.wholphin.data.model.SeerrServer
|
||||
|
|
@ -24,8 +25,10 @@ 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.firstOrNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
import okhttp3.OkHttpClient
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
|
@ -44,18 +47,33 @@ class SeerrServerRepository
|
|||
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 }
|
||||
private val _connection =
|
||||
MutableStateFlow<SeerrConnectionStatus>(SeerrConnectionStatus.NotConfigured)
|
||||
val connection: StateFlow<SeerrConnectionStatus> = _connection
|
||||
|
||||
val current: Flow<CurrentSeerr?> =
|
||||
_connection.map { (it as? SeerrConnectionStatus.Success)?.current }
|
||||
val currentServer: Flow<SeerrServer?> =
|
||||
connection.map { (it as? SeerrConnectionStatus.Success)?.current?.server }
|
||||
val currentUser: Flow<SeerrUser?> =
|
||||
connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user }
|
||||
|
||||
/**
|
||||
* Whether Seerr integration is currently active of not
|
||||
*/
|
||||
val active: Flow<Boolean> = current.map { it != null && seerrApi.active }
|
||||
val active: Flow<Boolean> =
|
||||
connection.map { it is SeerrConnectionStatus.Success && seerrApi.active }
|
||||
|
||||
fun clear() {
|
||||
_current.update { null }
|
||||
_connection.update { SeerrConnectionStatus.NotConfigured }
|
||||
seerrApi.update("", null)
|
||||
}
|
||||
|
||||
fun error(
|
||||
serverUrl: String,
|
||||
exception: Exception,
|
||||
) {
|
||||
_connection.update { SeerrConnectionStatus.Error(serverUrl, exception) }
|
||||
seerrApi.update("", null)
|
||||
}
|
||||
|
||||
|
|
@ -65,8 +83,10 @@ class SeerrServerRepository
|
|||
userConfig: SeerrUserConfig,
|
||||
) {
|
||||
val publicSettings = seerrApi.api.settingsApi.settingsPublicGet()
|
||||
_current.update {
|
||||
CurrentSeerr(server, user, userConfig, publicSettings)
|
||||
_connection.update {
|
||||
SeerrConnectionStatus.Success(
|
||||
CurrentSeerr(server, user, userConfig, publicSettings),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -145,7 +165,7 @@ class SeerrServerRepository
|
|||
apiKey,
|
||||
okHttpClient
|
||||
.newBuilder()
|
||||
.connectTimeout(5.seconds)
|
||||
.connectTimeout(2.seconds)
|
||||
.readTimeout(6.seconds)
|
||||
.build(),
|
||||
)
|
||||
|
|
@ -153,10 +173,11 @@ class SeerrServerRepository
|
|||
return LoadingState.Success
|
||||
}
|
||||
|
||||
suspend fun removeServer() {
|
||||
val current = _current.value ?: return
|
||||
seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId)
|
||||
suspend fun removeServerForCurrentUser(): Boolean {
|
||||
val current = current.firstOrNull() ?: return false
|
||||
val rows = seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId)
|
||||
clear()
|
||||
return rows > 0
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -165,6 +186,19 @@ class SeerrServerRepository
|
|||
*/
|
||||
typealias SeerrUserConfig = User
|
||||
|
||||
sealed interface SeerrConnectionStatus {
|
||||
data object NotConfigured : SeerrConnectionStatus
|
||||
|
||||
data class Error(
|
||||
val serverUrl: String,
|
||||
val ex: Exception,
|
||||
) : SeerrConnectionStatus
|
||||
|
||||
data class Success(
|
||||
val current: CurrentSeerr,
|
||||
) : SeerrConnectionStatus
|
||||
}
|
||||
|
||||
data class CurrentSeerr(
|
||||
val server: SeerrServer,
|
||||
val user: SeerrUser,
|
||||
|
|
@ -226,6 +260,7 @@ class UserSwitchListener
|
|||
private val seerrServerRepository: SeerrServerRepository,
|
||||
private val seerrServerDao: SeerrServerDao,
|
||||
private val seerrApi: SeerrApi,
|
||||
private val homeSettingsService: HomeSettingsService,
|
||||
) {
|
||||
init {
|
||||
context as AppCompatActivity
|
||||
|
|
@ -233,43 +268,58 @@ class UserSwitchListener
|
|||
serverRepository.currentUser.asFlow().collect { user ->
|
||||
Timber.d("New user")
|
||||
seerrServerRepository.clear()
|
||||
homeSettingsService.currentSettings.update { HomePageResolvedSettings.EMPTY }
|
||||
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)
|
||||
}
|
||||
}
|
||||
switchUser(user)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun switchUser(user: JellyfinUser) =
|
||||
supervisorScope {
|
||||
// Check for home settings
|
||||
launchIO {
|
||||
homeSettingsService.loadCurrentSettings(user.id)
|
||||
}
|
||||
// Check for seerr server
|
||||
launchIO {
|
||||
seerrServerDao
|
||||
.getUsersByJellyfinUser(user.rowId)
|
||||
.lastOrNull()
|
||||
?.let { seerrUser ->
|
||||
val server =
|
||||
seerrServerDao.getServer(seerrUser.serverId)?.server
|
||||
if (server != null) {
|
||||
Timber.i("Found a seerr user & server")
|
||||
try {
|
||||
seerrApi.update(server.url, seerrUser.credential)
|
||||
val userConfig =
|
||||
if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
|
||||
login(
|
||||
seerrApi.api,
|
||||
seerrUser.authMethod,
|
||||
seerrUser.username,
|
||||
seerrUser.password,
|
||||
)
|
||||
} else {
|
||||
seerrApi.api.usersApi.authMeGet()
|
||||
}
|
||||
seerrServerRepository.set(
|
||||
server,
|
||||
seerrUser,
|
||||
userConfig,
|
||||
)
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(
|
||||
ex,
|
||||
"Error logging into %s",
|
||||
server.url,
|
||||
)
|
||||
seerrServerRepository.error(server.url, ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import javax.inject.Singleton
|
|||
class SetupNavigationManager
|
||||
@Inject
|
||||
constructor() {
|
||||
var backStack: MutableList<NavKey> = mutableStateListOf(SetupDestination.Loading)
|
||||
var backStack: MutableList<SetupDestination> = mutableStateListOf(SetupDestination.Loading)
|
||||
|
||||
/**
|
||||
* Go to the specified [SetupDestination]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import androidx.lifecycle.asFlow
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
sealed interface SuggestionsResource {
|
||||
data object Loading : SuggestionsResource
|
||||
|
||||
data class Success(
|
||||
val items: List<BaseItem>,
|
||||
) : SuggestionsResource
|
||||
|
||||
data object Empty : SuggestionsResource
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class SuggestionService
|
||||
@Inject
|
||||
constructor(
|
||||
private val api: ApiClient,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val cache: SuggestionsCache,
|
||||
private val workManager: WorkManager,
|
||||
) {
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun getSuggestionsFlow(
|
||||
parentId: UUID,
|
||||
itemKind: BaseItemKind,
|
||||
): Flow<SuggestionsResource> {
|
||||
return serverRepository.currentUser
|
||||
.asFlow()
|
||||
.flatMapLatest { user ->
|
||||
val userId = user?.id ?: return@flatMapLatest flowOf(SuggestionsResource.Empty)
|
||||
val cachedSuggestions = cache.get(userId, parentId, itemKind)
|
||||
if (cachedSuggestions == null) {
|
||||
workManager
|
||||
.getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME)
|
||||
.map { workInfos ->
|
||||
val isActive =
|
||||
workInfos.any {
|
||||
it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED
|
||||
}
|
||||
if (isActive) SuggestionsResource.Loading else SuggestionsResource.Empty
|
||||
}
|
||||
} else if (cachedSuggestions.ids.isEmpty()) {
|
||||
flowOf(SuggestionsResource.Empty)
|
||||
} else {
|
||||
flow {
|
||||
try {
|
||||
emit(
|
||||
SuggestionsResource.Success(
|
||||
fetchItemsByIds(cachedSuggestions.ids, itemKind),
|
||||
),
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to fetch items")
|
||||
emit(SuggestionsResource.Empty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchItemsByIds(
|
||||
ids: List<UUID>,
|
||||
itemKind: BaseItemKind,
|
||||
): List<BaseItem> {
|
||||
val isSeries = itemKind == BaseItemKind.SERIES
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
ids = ids,
|
||||
fields = listOf(ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ItemFields.OVERVIEW),
|
||||
)
|
||||
return GetItemsRequestHandler
|
||||
.execute(api, request)
|
||||
.content.items
|
||||
.map { BaseItem.from(it, api, isSeries) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
@file:UseSerializers(UUIDSerializer::class)
|
||||
|
||||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import com.mayakapps.kache.InMemoryKache
|
||||
import com.mayakapps.kache.ObjectKache
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.UseSerializers
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
import kotlinx.serialization.json.encodeToStream
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Serializable
|
||||
data class CachedSuggestions(
|
||||
val ids: List<UUID>,
|
||||
)
|
||||
|
||||
@Singleton
|
||||
class SuggestionsCache
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
) {
|
||||
private val cacheDir: File
|
||||
get() = File(context.cacheDir, "suggestions")
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val mutex = Mutex()
|
||||
|
||||
private val memoryCache =
|
||||
InMemoryKache<String, CachedSuggestions>(maxSize = 8) {
|
||||
creationScope = CoroutineScope(Dispatchers.IO)
|
||||
}
|
||||
|
||||
private fun cacheKey(
|
||||
userId: UUID,
|
||||
libraryId: UUID,
|
||||
itemKind: BaseItemKind,
|
||||
) = "${userId.toServerString()}_${libraryId.toServerString()}_${itemKind.serialName}"
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
suspend fun get(
|
||||
userId: UUID,
|
||||
libraryId: UUID,
|
||||
itemKind: BaseItemKind,
|
||||
): CachedSuggestions? {
|
||||
val key = cacheKey(userId, libraryId, itemKind)
|
||||
return memoryCache.getOrPut(key) {
|
||||
mutex.withLock {
|
||||
try {
|
||||
val cacheFile = File(cacheDir, "$key.json")
|
||||
if (!cacheFile.exists()) {
|
||||
return@withLock null
|
||||
}
|
||||
|
||||
cacheFile.inputStream().use {
|
||||
json.decodeFromStream<CachedSuggestions>(it)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Exception reading from disk cache")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
suspend fun put(
|
||||
userId: UUID,
|
||||
libraryId: UUID,
|
||||
itemKind: BaseItemKind,
|
||||
ids: List<UUID>,
|
||||
) {
|
||||
val key = cacheKey(userId, libraryId, itemKind)
|
||||
val suggestions = CachedSuggestions(ids)
|
||||
memoryCache.put(key, suggestions)
|
||||
try {
|
||||
cacheDir.mkdirs()
|
||||
mutex.withLock {
|
||||
File(cacheDir, "$key.json").outputStream().use {
|
||||
json.encodeToStream(suggestions, it)
|
||||
}
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Exception writing to disk cache")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun ObjectKache<*, *>.isEmpty(): Boolean = this.size == 0L
|
||||
|
||||
fun ObjectKache<*, *>.isNotEmpty(): Boolean = !isEmpty()
|
||||
|
||||
suspend fun <T : Any> ObjectKache<T, *>.containsKey(key: T): Boolean = get(key) != null
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.workDataOf
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import dagger.hilt.android.qualifiers.ActivityContext
|
||||
import dagger.hilt.android.scopes.ActivityScoped
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.toJavaDuration
|
||||
|
||||
@ActivityScoped
|
||||
class SuggestionsSchedulerService
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ActivityContext private val context: Context,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val workManager: WorkManager,
|
||||
) {
|
||||
private val activity =
|
||||
(context as? AppCompatActivity)
|
||||
?: throw IllegalStateException(
|
||||
"SuggestionsSchedulerService requires an AppCompatActivity context, but received: ${context::class.java.name}",
|
||||
)
|
||||
|
||||
// Exposed for testing
|
||||
internal var dispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||
internal var initialDelaySecondsProvider: () -> Long = { 60L + Random.nextLong(0L, 121L) }
|
||||
|
||||
init {
|
||||
serverRepository.current.observe(activity) { user ->
|
||||
Timber.v("New user %s", user?.user?.id)
|
||||
if (user == null) {
|
||||
workManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME)
|
||||
} else {
|
||||
activity.lifecycleScope.launch(dispatcher + ExceptionHandler()) {
|
||||
scheduleWork(user.user.id, user.server.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun scheduleWork(
|
||||
userId: UUID,
|
||||
serverId: UUID,
|
||||
) {
|
||||
val workInfos =
|
||||
withContext(dispatcher) {
|
||||
workManager
|
||||
.getWorkInfosForUniqueWork(SuggestionsWorker.WORK_NAME)
|
||||
.get()
|
||||
}
|
||||
val activeWork =
|
||||
workInfos.firstOrNull {
|
||||
!it.state.isFinished
|
||||
}
|
||||
val scheduledUserId =
|
||||
activeWork
|
||||
?.tags
|
||||
?.firstOrNull { it.startsWith("user:") }
|
||||
?.removePrefix("user:")
|
||||
|
||||
val isAlreadyScheduledForUser = scheduledUserId == userId.toString()
|
||||
if (isAlreadyScheduledForUser) {
|
||||
Timber.d("SuggestionsWorker already scheduled for user %s", userId)
|
||||
return
|
||||
}
|
||||
|
||||
val constraints =
|
||||
Constraints
|
||||
.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
|
||||
val inputData =
|
||||
workDataOf(
|
||||
SuggestionsWorker.PARAM_USER_ID to userId.toString(),
|
||||
SuggestionsWorker.PARAM_SERVER_ID to serverId.toString(),
|
||||
)
|
||||
|
||||
val periodicWorkRequestBuilder =
|
||||
PeriodicWorkRequestBuilder<SuggestionsWorker>(
|
||||
repeatInterval = 12.hours.toJavaDuration(),
|
||||
flexTimeInterval = 1.hours.toJavaDuration(),
|
||||
).setConstraints(constraints)
|
||||
.setBackoffCriteria(
|
||||
BackoffPolicy.EXPONENTIAL,
|
||||
15.minutes.toJavaDuration(),
|
||||
).setInputData(inputData)
|
||||
.addTag("user:$userId")
|
||||
|
||||
val initialDelaySeconds = initialDelaySecondsProvider().coerceIn(60L, 180L)
|
||||
periodicWorkRequestBuilder.setInitialDelay(initialDelaySeconds.seconds.toJavaDuration())
|
||||
|
||||
Timber.i("Scheduling periodic SuggestionsWorker with ${initialDelaySeconds}s delay")
|
||||
|
||||
workManager.enqueueUniquePeriodicWork(
|
||||
uniqueWorkName = SuggestionsWorker.WORK_NAME,
|
||||
existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE,
|
||||
request = periodicWorkRequestBuilder.build(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,281 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.SortOrder
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
private val BaseItemDto.relevantId: UUID get() = seriesId ?: id
|
||||
|
||||
@HiltWorker
|
||||
class SuggestionsWorker
|
||||
@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 cache: SuggestionsCache,
|
||||
) : 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()) {
|
||||
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 itemsPerRow =
|
||||
prefs.homePagePreferences.maxItemsPerRow
|
||||
.takeIf { it > 0 }
|
||||
?: AppPreference.HomePageItems.defaultValue.toInt()
|
||||
|
||||
val views =
|
||||
api.userViewsApi
|
||||
.getUserViews(userId = userId)
|
||||
.content.items
|
||||
.orEmpty()
|
||||
if (views.isEmpty()) {
|
||||
return Result.success()
|
||||
}
|
||||
val results =
|
||||
supervisorScope {
|
||||
val context = Dispatchers.IO.limitedParallelism(2, "fetchSuggestions")
|
||||
views
|
||||
.mapNotNull { view ->
|
||||
val itemKind =
|
||||
getTypeForCollection(view.collectionType)
|
||||
?: return@mapNotNull null
|
||||
async(Dispatchers.IO) {
|
||||
runCatching {
|
||||
Timber.v("Fetching suggestions for view %s", view.id)
|
||||
val suggestions =
|
||||
fetchSuggestions(
|
||||
context,
|
||||
view.id,
|
||||
userId,
|
||||
itemKind,
|
||||
itemsPerRow,
|
||||
)
|
||||
ensureActive()
|
||||
val newIds = suggestions.map { it.id }
|
||||
val cachedIds = cache.get(userId, view.id, itemKind)?.ids
|
||||
if (cachedIds == newIds) {
|
||||
Timber.v("Suggestions unchanged for view %s, skipping cache write", view.id)
|
||||
return@runCatching
|
||||
}
|
||||
cache.put(
|
||||
userId,
|
||||
view.id,
|
||||
itemKind,
|
||||
newIds,
|
||||
)
|
||||
}.onFailure { e ->
|
||||
Timber.e(
|
||||
e,
|
||||
"Failed to fetch suggestions for view %s",
|
||||
view.id,
|
||||
)
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
val successCount = results.count { it.isSuccess }
|
||||
val failureCount = results.count { it.isFailure }
|
||||
if (failureCount > 0 && successCount == 0) {
|
||||
Timber.w("All attempts failed ($failureCount views), scheduling retry")
|
||||
return Result.retry()
|
||||
}
|
||||
Timber.d("Completed with $successCount successes and $failureCount failures")
|
||||
return Result.success()
|
||||
} catch (ex: ApiClientException) {
|
||||
Timber.w(ex, "SuggestionsWorker ApiClientException, will retry")
|
||||
return Result.retry()
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "SuggestionsWorker failed")
|
||||
return Result.failure()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchSuggestions(
|
||||
coroutineContext: CoroutineContext,
|
||||
parentId: UUID,
|
||||
userId: UUID,
|
||||
itemKind: BaseItemKind,
|
||||
itemsPerRow: Int,
|
||||
): List<BaseItemDto> =
|
||||
coroutineScope {
|
||||
val isSeries = itemKind == BaseItemKind.SERIES
|
||||
val historyItemType = if (isSeries) BaseItemKind.EPISODE else itemKind
|
||||
|
||||
val contextualLimit = (itemsPerRow * 0.4).toInt().coerceAtLeast(1)
|
||||
val randomLimit = (itemsPerRow * 0.3).toInt().coerceAtLeast(1)
|
||||
val freshLimit = (itemsPerRow * 0.3).toInt().coerceAtLeast(1)
|
||||
|
||||
val historyDeferred =
|
||||
async(coroutineContext) {
|
||||
fetchItems(
|
||||
parentId = parentId,
|
||||
userId = userId,
|
||||
itemKind = historyItemType,
|
||||
sortBy = ItemSortBy.DATE_PLAYED,
|
||||
isPlayed = true,
|
||||
limit = 20,
|
||||
extraFields = listOf(ItemFields.GENRES),
|
||||
).distinctBy { it.relevantId }.take(3)
|
||||
}
|
||||
|
||||
val seedItems = historyDeferred.await()
|
||||
|
||||
val allGenreIds =
|
||||
seedItems
|
||||
.flatMap { it.genreItems?.mapNotNull { g -> g.id } ?: emptyList() }
|
||||
.distinct()
|
||||
|
||||
val excludeIds = seedItems.mapTo(HashSet()) { it.relevantId }
|
||||
|
||||
val contextualDeferred =
|
||||
async(coroutineContext) {
|
||||
if (allGenreIds.isEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
fetchItems(
|
||||
parentId = parentId,
|
||||
userId = userId,
|
||||
itemKind = itemKind,
|
||||
sortBy = ItemSortBy.RANDOM,
|
||||
isPlayed = false,
|
||||
limit = contextualLimit,
|
||||
genreIds = allGenreIds,
|
||||
excludeItemIds = excludeIds.toList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val randomDeferred =
|
||||
async(coroutineContext) {
|
||||
fetchItems(
|
||||
parentId = parentId,
|
||||
userId = userId,
|
||||
itemKind = itemKind,
|
||||
sortBy = ItemSortBy.RANDOM,
|
||||
isPlayed = false,
|
||||
limit = randomLimit,
|
||||
)
|
||||
}
|
||||
|
||||
val freshDeferred =
|
||||
async(coroutineContext) {
|
||||
fetchItems(
|
||||
parentId = parentId,
|
||||
userId = userId,
|
||||
itemKind = itemKind,
|
||||
sortBy = ItemSortBy.DATE_CREATED,
|
||||
sortOrder = SortOrder.DESCENDING,
|
||||
isPlayed = false,
|
||||
limit = freshLimit,
|
||||
)
|
||||
}
|
||||
withContext(Dispatchers.Default) {
|
||||
val contextual = contextualDeferred.await()
|
||||
val random = randomDeferred.await()
|
||||
val fresh = freshDeferred.await()
|
||||
|
||||
(contextual + fresh + random)
|
||||
.asSequence()
|
||||
.distinctBy { it.id }
|
||||
.filterNot { excludeIds.contains(it.relevantId) }
|
||||
.toList()
|
||||
.shuffled()
|
||||
.take(itemsPerRow)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchItems(
|
||||
parentId: UUID,
|
||||
userId: UUID,
|
||||
itemKind: BaseItemKind,
|
||||
sortBy: ItemSortBy,
|
||||
isPlayed: Boolean,
|
||||
limit: Int,
|
||||
sortOrder: SortOrder? = null,
|
||||
genreIds: List<UUID>? = null,
|
||||
excludeItemIds: List<UUID>? = null,
|
||||
extraFields: List<ItemFields> = emptyList(),
|
||||
): List<BaseItemDto> {
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
parentId = parentId,
|
||||
userId = userId,
|
||||
fields = extraFields,
|
||||
includeItemTypes = listOf(itemKind),
|
||||
genreIds = genreIds,
|
||||
recursive = true,
|
||||
isPlayed = isPlayed,
|
||||
excludeItemIds = excludeItemIds,
|
||||
sortBy = listOf(sortBy),
|
||||
sortOrder = sortOrder?.let { listOf(it) },
|
||||
limit = limit,
|
||||
enableTotalRecordCount = false,
|
||||
imageTypeLimit = 0,
|
||||
)
|
||||
return GetItemsRequestHandler
|
||||
.execute(api, request)
|
||||
.content.items
|
||||
.orEmpty()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val WORK_NAME = "com.github.damontecres.wholphin.services.SuggestionsWorker"
|
||||
const val PARAM_USER_ID = "userId"
|
||||
const val PARAM_SERVER_ID = "serverId"
|
||||
|
||||
fun getTypeForCollection(collectionType: CollectionType?): BaseItemKind? =
|
||||
when (collectionType) {
|
||||
CollectionType.MOVIES -> BaseItemKind.MOVIE
|
||||
CollectionType.TVSHOWS -> BaseItemKind.SERIES
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import com.github.damontecres.wholphin.data.ServerRepository
|
|||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
|
|
@ -15,6 +16,8 @@ class UserPreferencesService
|
|||
private val serverRepository: ServerRepository,
|
||||
private val preferencesDataStore: DataStore<AppPreferences>,
|
||||
) {
|
||||
val flow = preferencesDataStore.data.map { UserPreferences(it) }
|
||||
|
||||
suspend fun getCurrent(): UserPreferences =
|
||||
serverRepository.currentUserDto.value!!.configuration.let { userConfig ->
|
||||
val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.services.hilt
|
|||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.work.WorkManager
|
||||
import com.github.damontecres.wholphin.BuildConfig
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
|
|
@ -9,6 +10,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
|
|||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
|
||||
import com.github.damontecres.wholphin.services.SeerrApi
|
||||
import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.RememberTabManager
|
||||
import dagger.Module
|
||||
|
|
@ -16,6 +18,7 @@ import dagger.Provides
|
|||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
|
|
@ -43,6 +46,18 @@ annotation class StandardOkHttpClient
|
|||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class IoCoroutineScope
|
||||
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class DefaultCoroutineScope
|
||||
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class IoDispatcher
|
||||
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class DefaultDispatcher
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AppModule {
|
||||
|
|
@ -56,12 +71,6 @@ object AppModule {
|
|||
version = BuildConfig.VERSION_NAME,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun deviceInfo(
|
||||
@ApplicationContext context: Context,
|
||||
): DeviceInfo = androidDevice(context)
|
||||
|
||||
@StandardOkHttpClient
|
||||
@Provides
|
||||
@Singleton
|
||||
|
|
@ -106,12 +115,12 @@ object AppModule {
|
|||
@Singleton
|
||||
fun okHttpFactory(
|
||||
@StandardOkHttpClient okHttpClient: OkHttpClient,
|
||||
) = OkHttpFactory(okHttpClient)
|
||||
) = CoroutineContextApiClientFactory(OkHttpFactory(okHttpClient))
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun jellyfin(
|
||||
okHttpFactory: OkHttpFactory,
|
||||
okHttpFactory: CoroutineContextApiClientFactory,
|
||||
@ApplicationContext context: Context,
|
||||
clientInfo: ClientInfo,
|
||||
deviceInfo: DeviceInfo,
|
||||
|
|
@ -171,10 +180,35 @@ object AppModule {
|
|||
}
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@IoDispatcher
|
||||
fun ioDispatcher(): CoroutineDispatcher = Dispatchers.IO
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@IoCoroutineScope
|
||||
fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
fun ioCoroutineScope(
|
||||
@IoDispatcher dispatcher: CoroutineDispatcher,
|
||||
): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@DefaultDispatcher
|
||||
fun defaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@DefaultCoroutineScope
|
||||
fun defaultCoroutineScope(
|
||||
@DefaultDispatcher dispatcher: CoroutineDispatcher,
|
||||
): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun workManager(
|
||||
@ApplicationContext context: Context,
|
||||
): WorkManager = WorkManager.getInstance(context)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
|
|
@ -182,3 +216,13 @@ object AppModule {
|
|||
@StandardOkHttpClient okHttpClient: OkHttpClient,
|
||||
) = SeerrApi(okHttpClient)
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object DeviceModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun deviceInfo(
|
||||
@ApplicationContext context: Context,
|
||||
): DeviceInfo = androidDevice(context)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.data.ItemPlaybackDao
|
|||
import com.github.damontecres.wholphin.data.JellyfinServerDao
|
||||
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
|
||||
import com.github.damontecres.wholphin.data.Migrations
|
||||
import com.github.damontecres.wholphin.data.PlaybackEffectDao
|
||||
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
|
||||
import com.github.damontecres.wholphin.data.SeerrServerDao
|
||||
import com.github.damontecres.wholphin.data.ServerPreferencesDao
|
||||
|
|
@ -66,6 +67,10 @@ object DatabaseModule {
|
|||
@Singleton
|
||||
fun seerrServerDao(db: AppDatabase): SeerrServerDao = db.seerrServerDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun playbackEffectDao(db: AppDatabase): PlaybackEffectDao = db.playbackEffectDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun userPreferencesDataStore(
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import timber.log.Timber
|
|||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.toJavaDuration
|
||||
|
||||
@ActivityScoped
|
||||
|
|
@ -29,9 +30,9 @@ class TvProviderSchedulerService
|
|||
constructor(
|
||||
@param:ActivityContext private val context: Context,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val workManager: WorkManager,
|
||||
) {
|
||||
private val activity = (context as AppCompatActivity)
|
||||
private val workManager = WorkManager.getInstance(context)
|
||||
|
||||
private val supportsTvProvider =
|
||||
// TODO <=25 has limited support
|
||||
|
|
@ -60,7 +61,8 @@ class TvProviderSchedulerService
|
|||
TvProviderWorker.PARAM_USER_ID to user.user.id.toString(),
|
||||
TvProviderWorker.PARAM_SERVER_ID to user.server.id.toString(),
|
||||
),
|
||||
).build(),
|
||||
).setInitialDelay(60.seconds.toJavaDuration())
|
||||
.build(),
|
||||
).await()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ class TvProviderWorker
|
|||
getPotentialItems(
|
||||
userId,
|
||||
prefs.homePagePreferences.enableRewatchingNextUp,
|
||||
prefs.homePagePreferences.combineContinueNext,
|
||||
prefs.homePagePreferences.maxDaysNextUp,
|
||||
)
|
||||
val potentialItemsToAddIds = potentialItemsToAdd.map { it.id.toString() }
|
||||
|
||||
|
|
@ -144,19 +144,15 @@ class TvProviderWorker
|
|||
private suspend fun getPotentialItems(
|
||||
userId: UUID,
|
||||
enableRewatching: Boolean,
|
||||
combineContinueNext: Boolean,
|
||||
maxDaysNextUp: Int,
|
||||
): List<BaseItem> {
|
||||
val resumeItems = latestNextUpService.getResume(userId, 10, true)
|
||||
val seriesIds = resumeItems.mapNotNull { it.data.seriesId }
|
||||
val nextUpItems =
|
||||
latestNextUpService
|
||||
.getNextUp(userId, 10, enableRewatching, false)
|
||||
.getNextUp(userId, 10, enableRewatching, false, maxDaysNextUp)
|
||||
.filter { it.data.seriesId != null && it.data.seriesId !in seriesIds }
|
||||
return if (combineContinueNext) {
|
||||
latestNextUpService.buildCombined(resumeItems, nextUpItems)
|
||||
} else {
|
||||
resumeItems + nextUpItems
|
||||
}
|
||||
return latestNextUpService.buildCombined(resumeItems, nextUpItems)
|
||||
}
|
||||
|
||||
private suspend fun getCurrentTvChannelNextUp(): List<WatchNextProgram> =
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import android.content.Context
|
|||
import android.content.ContextWrapper
|
||||
import android.media.AudioManager
|
||||
import android.view.KeyEvent
|
||||
import android.view.WindowManager
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.MarqueeAnimationMode
|
||||
import androidx.compose.foundation.basicMarquee
|
||||
|
|
@ -46,7 +45,6 @@ import org.jellyfin.sdk.api.client.ApiClient
|
|||
import org.jellyfin.sdk.api.client.Response
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
|
|
@ -296,7 +294,7 @@ fun Arrangement.spacedByWithFooter(space: Dp) =
|
|||
}
|
||||
|
||||
/**
|
||||
* Tries to find the [Activity] for the given [Context]. Often used for [keepScreenOn].
|
||||
* Tries to find the [Activity] for the given [Context]
|
||||
*/
|
||||
fun Context.findActivity(): Activity? {
|
||||
if (this is Activity) {
|
||||
|
|
@ -310,18 +308,6 @@ fun Context.findActivity(): Activity? {
|
|||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the screen on for an [Activity]. Often used with [findActivity].
|
||||
*/
|
||||
fun Activity.keepScreenOn(keep: Boolean) {
|
||||
Timber.v("Keep screen on: $keep")
|
||||
if (keep) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} else {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Selectively log errors from Coil image loading.
|
||||
*
|
||||
|
|
@ -391,6 +377,15 @@ fun CoroutineScope.launchIO(
|
|||
block: suspend CoroutineScope.() -> Unit,
|
||||
): Job = launch(context = Dispatchers.IO + context, start = start, block = block)
|
||||
|
||||
/**
|
||||
* Launches a coroutine with [Dispatchers.Default] plus the provided [CoroutineContext] defaulting to using [ExceptionHandler]
|
||||
*/
|
||||
fun CoroutineScope.launchDefault(
|
||||
context: CoroutineContext = ExceptionHandler(),
|
||||
start: CoroutineStart = CoroutineStart.DEFAULT,
|
||||
block: suspend CoroutineScope.() -> Unit,
|
||||
): Job = launch(context = Dispatchers.Default + context, start = start, block = block)
|
||||
|
||||
/**
|
||||
* Converts a UUID to the format used server-side (ie without hyphens).
|
||||
*
|
||||
|
|
@ -433,12 +428,6 @@ fun Response<BaseItemDtoQueryResult>.toBaseItems(
|
|||
useSeriesForPrimary: Boolean,
|
||||
) = this.content.items.map { BaseItem.from(it, api, useSeriesForPrimary) }
|
||||
|
||||
@Composable
|
||||
fun rememberBackDropImage(item: BaseItem): String? {
|
||||
val imageUrlService = LocalImageUrlService.current
|
||||
return remember(item) { imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this, coalescing nulls to zero, is greater than that
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ 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.WholphinApplication
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.MediaSegmentType
|
||||
import timber.log.Timber
|
||||
|
|
@ -76,7 +77,7 @@ val BaseItemDto.seriesProductionYears: String?
|
|||
append(productionYear.toString())
|
||||
if (status == "Continuing") {
|
||||
append(" - ")
|
||||
append("Present")
|
||||
append(WholphinApplication.instance.getString(R.string.series_continueing))
|
||||
} else if (status == "Ended") {
|
||||
endDate?.let {
|
||||
if (it.year != productionYear) {
|
||||
|
|
@ -167,19 +168,19 @@ fun listToDotString(
|
|||
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")
|
||||
}
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,8 @@ val DefaultItemFields =
|
|||
ItemFields.CHAPTERS,
|
||||
ItemFields.MEDIA_SOURCES,
|
||||
ItemFields.MEDIA_SOURCE_COUNT,
|
||||
ItemFields.PARENT_ID,
|
||||
ItemFields.CAN_DELETE,
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
@ -70,11 +72,22 @@ val SlimItemFields =
|
|||
ItemFields.OVERVIEW,
|
||||
ItemFields.SORT_NAME,
|
||||
ItemFields.MEDIA_SOURCE_COUNT,
|
||||
ItemFields.PARENT_ID,
|
||||
ItemFields.CAN_DELETE,
|
||||
)
|
||||
|
||||
val PhotoItemFields =
|
||||
DefaultItemFields +
|
||||
listOf(
|
||||
ItemFields.WIDTH,
|
||||
ItemFields.HEIGHT,
|
||||
)
|
||||
|
||||
object Cards {
|
||||
val height2x3 = 172.dp
|
||||
val heightEpisode = height2x3 * .75f
|
||||
const val HEIGHT_2X3_DP = 172
|
||||
val height2x3 = HEIGHT_2X3_DP.dp
|
||||
const val HEIGHT_EPISODE = 128
|
||||
val heightEpisode = HEIGHT_EPISODE.dp
|
||||
val playedPercentHeight = 6.dp
|
||||
val serverUserCircle = height2x3 * .75f
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
package com.github.damontecres.wholphin.ui.cards
|
||||
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -24,6 +28,7 @@ import androidx.compose.ui.layout.ContentScale
|
|||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -41,6 +46,7 @@ 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.LocalImageUrlService
|
||||
import com.github.damontecres.wholphin.ui.enableMarquee
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
|
||||
/**
|
||||
|
|
@ -60,6 +66,9 @@ fun BannerCard(
|
|||
cardHeight: Dp = 120.dp,
|
||||
aspectRatio: Float = AspectRatios.WIDE,
|
||||
interactionSource: MutableInteractionSource? = null,
|
||||
imageType: ImageType = ImageType.PRIMARY,
|
||||
imageContentScale: ContentScale = ContentScale.FillBounds,
|
||||
useSeriesForPrimary: Boolean = true,
|
||||
) {
|
||||
val imageUrlService = LocalImageUrlService.current
|
||||
val density = LocalDensity.current
|
||||
|
|
@ -74,19 +83,21 @@ fun BannerCard(
|
|||
}
|
||||
}
|
||||
val imageUrl =
|
||||
remember(item, fillHeight) {
|
||||
remember(item, fillHeight, imageType, useSeriesForPrimary) {
|
||||
if (item != null) {
|
||||
imageUrlService.getItemImageUrl(
|
||||
item,
|
||||
ImageType.PRIMARY,
|
||||
fillWidth = null,
|
||||
fillHeight = fillHeight,
|
||||
)
|
||||
item.imageUrlOverride
|
||||
?: imageUrlService.getItemImageUrl(
|
||||
item,
|
||||
imageType,
|
||||
fillWidth = null,
|
||||
fillHeight = fillHeight,
|
||||
useSeriesForPrimary = useSeriesForPrimary,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
var imageError by remember { mutableStateOf(false) }
|
||||
var imageError by remember(imageUrl) { mutableStateOf(false) }
|
||||
Card(
|
||||
modifier = modifier.size(cardHeight * aspectRatio, cardHeight),
|
||||
onClick = onClick,
|
||||
|
|
@ -107,7 +118,7 @@ fun BannerCard(
|
|||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.FillBounds,
|
||||
contentScale = imageContentScale,
|
||||
onError = { imageError = true },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
|
|
@ -115,7 +126,7 @@ fun BannerCard(
|
|||
Text(
|
||||
text = name ?: "",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier =
|
||||
Modifier
|
||||
|
|
@ -181,3 +192,84 @@ fun BannerCard(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BannerCardWithTitle(
|
||||
title: String?,
|
||||
subtitle: String?,
|
||||
item: BaseItem?,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
cornerText: String? = null,
|
||||
played: Boolean = false,
|
||||
favorite: Boolean = false,
|
||||
playPercent: Double = 0.0,
|
||||
cardHeight: Dp = 120.dp,
|
||||
aspectRatio: Float = AspectRatios.WIDE,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
imageType: ImageType = ImageType.PRIMARY,
|
||||
imageContentScale: ContentScale = ContentScale.FillBounds,
|
||||
useSeriesForPrimary: Boolean = item?.useSeriesForPrimary ?: true,
|
||||
) {
|
||||
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)
|
||||
val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
|
||||
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
|
||||
val width = cardHeight * aspectRationToUse
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(spaceBetween),
|
||||
modifier = modifier.width(width),
|
||||
) {
|
||||
BannerCard(
|
||||
name = null,
|
||||
item = item,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = Modifier,
|
||||
cornerText = cornerText,
|
||||
played = played,
|
||||
favorite = favorite,
|
||||
playPercent = playPercent,
|
||||
cardHeight = cardHeight,
|
||||
aspectRatio = aspectRatio,
|
||||
interactionSource = interactionSource,
|
||||
imageType = imageType,
|
||||
imageContentScale = imageContentScale,
|
||||
useSeriesForPrimary = useSeriesForPrimary,
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(bottom = spaceBelow)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = title ?: "",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp)
|
||||
.enableMarquee(focusedAfterDelay),
|
||||
)
|
||||
Text(
|
||||
text = subtitle ?: "",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp)
|
||||
.enableMarquee(focusedAfterDelay),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ fun ChapterRow(
|
|||
text = stringResource(R.string.chapters),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
LazyRow(
|
||||
state = rememberLazyListState(),
|
||||
|
|
@ -41,7 +42,6 @@ fun ChapterRow(
|
|||
contentPadding = PaddingValues(vertical = 8.dp, horizontal = 16.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 16.dp)
|
||||
.fillMaxWidth()
|
||||
.focusRestorer(),
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ 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.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -183,6 +184,8 @@ fun DiscoverItemCard(
|
|||
text = item?.title ?: "",
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -193,6 +196,8 @@ fun DiscoverItemCard(
|
|||
text = item?.releaseDate?.year?.toString() ?: "",
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Normal,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
|
|||
|
|
@ -22,11 +22,13 @@ 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.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.Card
|
||||
import androidx.tv.material3.CardDefaults
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.ui.AppColors
|
||||
|
|
@ -130,6 +132,8 @@ fun EpisodeCard(
|
|||
text = dto?.seriesName ?: "",
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -140,6 +144,8 @@ fun EpisodeCard(
|
|||
text = item?.name ?: "",
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Normal,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
|
|||
|
|
@ -42,11 +42,29 @@ fun GenreCard(
|
|||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) = GenreCard(
|
||||
genreId = genre?.id,
|
||||
name = genre?.name,
|
||||
imageUrl = genre?.imageUrl,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = modifier,
|
||||
interactionSource = interactionSource,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun GenreCard(
|
||||
genreId: UUID?,
|
||||
name: String?,
|
||||
imageUrl: String?,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
val background = rememberIdColor(genre?.id).copy(alpha = .6f)
|
||||
val background = rememberIdColor(genreId).copy(alpha = .6f)
|
||||
Card(
|
||||
modifier =
|
||||
modifier,
|
||||
modifier = modifier,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
interactionSource = interactionSource,
|
||||
|
|
@ -63,12 +81,12 @@ fun GenreCard(
|
|||
.fillMaxSize()
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
) {
|
||||
if (genre?.imageUrl.isNotNullOrBlank()) {
|
||||
if (imageUrl != null) {
|
||||
AsyncImage(
|
||||
model =
|
||||
ImageRequest
|
||||
.Builder(LocalContext.current)
|
||||
.data(genre.imageUrl)
|
||||
.data(imageUrl)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentScale = ContentScale.FillBounds,
|
||||
|
|
@ -88,7 +106,7 @@ fun GenreCard(
|
|||
.background(background),
|
||||
) {
|
||||
Text(
|
||||
text = genre?.name ?: "",
|
||||
text = name ?: "",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
|
|
@ -112,7 +130,6 @@ private fun GenreCardPreview() {
|
|||
UUID.randomUUID(),
|
||||
"Adventure",
|
||||
null,
|
||||
Color.Black,
|
||||
)
|
||||
GenreCard(
|
||||
genre = genre,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import androidx.compose.runtime.setValue
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -113,6 +114,8 @@ fun GridCard(
|
|||
text = item?.title ?: "",
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier =
|
||||
Modifier
|
||||
|
|
@ -124,6 +127,8 @@ fun GridCard(
|
|||
text = item?.subtitle ?: "",
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Normal,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Arrangement
|
|||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
|
|
@ -57,6 +58,7 @@ fun <T> ItemRow(
|
|||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
LazyRow(
|
||||
state = state,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import androidx.compose.ui.draw.clip
|
|||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
|
@ -174,6 +175,8 @@ fun PersonCard(
|
|||
text = name ?: "",
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -185,6 +188,8 @@ fun PersonCard(
|
|||
text = role,
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Normal,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ fun PersonRow(
|
|||
text = stringResource(title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
LazyRow(
|
||||
state = rememberLazyListState(),
|
||||
|
|
@ -53,7 +54,6 @@ fun PersonRow(
|
|||
contentPadding = PaddingValues(8.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 16.dp)
|
||||
.fillMaxWidth()
|
||||
.focusRestorer(firstFocus),
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -16,15 +16,16 @@ import androidx.compose.runtime.LaunchedEffect
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.Card
|
||||
import androidx.tv.material3.CardDefaults
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||
|
|
@ -126,21 +127,7 @@ fun SeasonCard(
|
|||
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 focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
|
||||
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
|
||||
val width = imageHeight * aspectRationToUse
|
||||
val height = imageWidth * (1f / aspectRationToUse)
|
||||
|
|
@ -193,6 +180,8 @@ fun SeasonCard(
|
|||
text = title ?: "",
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -203,6 +192,8 @@ fun SeasonCard(
|
|||
text = subtitle ?: "",
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Normal,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -212,3 +203,22 @@ fun SeasonCard(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a [androidx.compose.runtime.State] which represents if the item has been focused for a while
|
||||
*/
|
||||
@Composable
|
||||
fun rememberFocusedAfterDelay(interactionSource: MutableInteractionSource): androidx.compose.runtime.State<Boolean> {
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
val state = remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(focused) {
|
||||
if (!focused) {
|
||||
state.value = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
delay(500L)
|
||||
state.value = true
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,215 @@
|
|||
package com.github.damontecres.wholphin.ui.components
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
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.Box
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
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.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.geometry.center
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RadialGradientShader
|
||||
import androidx.compose.ui.graphics.Shader
|
||||
import androidx.compose.ui.graphics.ShaderBrush
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.datastore.core.DataStore
|
||||
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 coil3.annotation.ExperimentalCoilApi
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.useExistingImageAsPlaceholder
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.transitionFactory
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.services.ScreensaverService
|
||||
import com.github.damontecres.wholphin.ui.AppColors
|
||||
import com.github.damontecres.wholphin.ui.CrossFadeFactory
|
||||
import com.github.damontecres.wholphin.ui.nav.TOP_SCRIM_ALPHA
|
||||
import com.github.damontecres.wholphin.ui.nav.TOP_SCRIM_END_FRACTION
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@HiltViewModel
|
||||
class ScreensaverViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val screensaverService: ScreensaverService,
|
||||
val preferencesDataStore: DataStore<AppPreferences>,
|
||||
) : ViewModel() {
|
||||
val currentItem = screensaverService.createItemFlow(viewModelScope)
|
||||
}
|
||||
|
||||
data class CurrentItem(
|
||||
val item: BaseItem,
|
||||
val backdropUrl: String,
|
||||
val logoUrl: String?,
|
||||
val title: String,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun AppScreensaver(
|
||||
prefs: AppPreferences,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: ScreensaverViewModel = hiltViewModel(),
|
||||
) {
|
||||
val currentItem by viewModel.currentItem.collectAsState(null)
|
||||
AppScreensaverContent(
|
||||
currentItem = currentItem,
|
||||
showClock = prefs.interfacePreferences.screensaverPreference.showClock,
|
||||
duration = prefs.interfacePreferences.screensaverPreference.duration.milliseconds,
|
||||
animate = prefs.interfacePreferences.screensaverPreference.animate,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoilApi::class)
|
||||
@Composable
|
||||
fun AppScreensaverContent(
|
||||
currentItem: CurrentItem?,
|
||||
showClock: Boolean,
|
||||
duration: Duration,
|
||||
animate: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier
|
||||
.background(Color.Black),
|
||||
) {
|
||||
val infiniteTransition = rememberInfiniteTransition()
|
||||
val scale by infiniteTransition.animateFloat(
|
||||
1f,
|
||||
if (animate) 1.1f else 1f,
|
||||
infiniteRepeatable(
|
||||
tween(
|
||||
durationMillis = duration.inWholeMilliseconds.toInt(),
|
||||
delayMillis = 500,
|
||||
easing = LinearEasing,
|
||||
),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
)
|
||||
AsyncImage(
|
||||
model =
|
||||
ImageRequest
|
||||
.Builder(LocalContext.current)
|
||||
.data(currentItem?.backdropUrl)
|
||||
.transitionFactory(CrossFadeFactory(2000.milliseconds))
|
||||
.useExistingImageAsPlaceholder(true)
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
},
|
||||
)
|
||||
|
||||
var logoError by remember(currentItem) { mutableStateOf(false) }
|
||||
val alignment = listOf(Alignment.BottomStart, Alignment.BottomEnd).random()
|
||||
if (!logoError) {
|
||||
AsyncImage(
|
||||
model =
|
||||
ImageRequest
|
||||
.Builder(LocalContext.current)
|
||||
.data(currentItem?.logoUrl)
|
||||
.transitionFactory(CrossFadeFactory(750.milliseconds))
|
||||
.build(),
|
||||
contentDescription = "Logo",
|
||||
onError = {
|
||||
logoError = true
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.align(alignment)
|
||||
.size(width = 240.dp, height = 120.dp)
|
||||
.padding(16.dp),
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(alignment)
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth(.5f)
|
||||
.fillMaxHeight(.3f),
|
||||
) {
|
||||
Text(
|
||||
text = currentItem?.title ?: "",
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.align(alignment),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val largeRadialGradient =
|
||||
remember {
|
||||
object : ShaderBrush() {
|
||||
override fun createShader(size: Size): Shader {
|
||||
val biggerDimension = maxOf(size.height, size.width)
|
||||
return RadialGradientShader(
|
||||
colors = listOf(Color.Transparent, AppColors.TransparentBlack25),
|
||||
center = size.center,
|
||||
radius = biggerDimension / 1.5f,
|
||||
colorStops = listOf(0f, 0.85f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
drawRect(
|
||||
brush = largeRadialGradient,
|
||||
blendMode = BlendMode.Multiply,
|
||||
)
|
||||
if (showClock) {
|
||||
// Add scrim to make clock more readable
|
||||
drawRect(
|
||||
brush =
|
||||
Brush.verticalGradient(
|
||||
colorStops =
|
||||
arrayOf(
|
||||
0f to Color.Black.copy(alpha = TOP_SCRIM_ALPHA),
|
||||
TOP_SCRIM_END_FRACTION to Color.Transparent,
|
||||
),
|
||||
),
|
||||
blendMode = BlendMode.Multiply,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showClock) {
|
||||
TimeDisplay()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,11 +2,11 @@ package com.github.damontecres.wholphin.ui.components
|
|||
|
||||
import android.content.Context
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -33,6 +33,7 @@ import androidx.compose.ui.focus.FocusRequester
|
|||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
|
|
@ -41,6 +42,7 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
|
|
@ -55,10 +57,16 @@ import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
|||
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||
import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride
|
||||
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.MediaManagementService
|
||||
import com.github.damontecres.wholphin.services.MediaReportService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.services.deleteItem
|
||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
|
||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
|
|
@ -71,15 +79,19 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
|
|||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
||||
import com.github.damontecres.wholphin.ui.equalsNotNull
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.main.HomePageHeader
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.playback.scale
|
||||
import com.github.damontecres.wholphin.ui.rememberInt
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.ui.util.FilterUtils
|
||||
import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
|
||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
|
|
@ -92,6 +104,9 @@ import dagger.assisted.AssistedInject
|
|||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
|
|
@ -119,7 +134,11 @@ class CollectionFolderViewModel
|
|||
private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
|
||||
private val favoriteWatchManager: FavoriteWatchManager,
|
||||
private val backdropService: BackdropService,
|
||||
val navigationManager: NavigationManager,
|
||||
private val navigationManager: NavigationManager,
|
||||
private val themeSongPlayer: ThemeSongPlayer,
|
||||
private val userPreferencesService: UserPreferencesService,
|
||||
private val mediaManagementService: MediaManagementService,
|
||||
val mediaReportService: MediaReportService,
|
||||
@Assisted itemId: String,
|
||||
@Assisted initialSortAndDirection: SortAndDirection?,
|
||||
@Assisted("recursive") private val recursive: Boolean,
|
||||
|
|
@ -155,9 +174,10 @@ class CollectionFolderViewModel
|
|||
viewModelScope.launchIO {
|
||||
super.itemId = itemId
|
||||
try {
|
||||
itemId.toUUIDOrNull()?.let {
|
||||
fetchItem(it)
|
||||
}
|
||||
val item =
|
||||
itemId.toUUIDOrNull()?.let {
|
||||
fetchItem(it)
|
||||
}
|
||||
|
||||
val libraryDisplayInfo =
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
|
|
@ -182,11 +202,42 @@ class CollectionFolderViewModel
|
|||
}
|
||||
|
||||
loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary)
|
||||
.join()
|
||||
// onResumePage()
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error during init")
|
||||
loading.setValueOnMain(DataLoadingState.Error(ex))
|
||||
}
|
||||
}
|
||||
mediaManagementService.deletedItemFlow
|
||||
.onEach { deletedItem ->
|
||||
refreshAfterDelete(position, deletedItem.item)
|
||||
}.catch { ex ->
|
||||
Timber.e(ex, "Error refreshing after deleted item")
|
||||
}.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private suspend fun refreshAfterDelete(
|
||||
position: Int,
|
||||
deletedItem: BaseItem,
|
||||
) {
|
||||
try {
|
||||
val pager =
|
||||
((loading.value as? DataLoadingState.Success)?.data as? ApiRequestPager<*>)
|
||||
position.let {
|
||||
Timber.v("Item deleted: position=%s, id=%s", it, itemId)
|
||||
val item = pager?.get(it)
|
||||
// Exact item deleted (eg a movie) or deleted item was within the series
|
||||
if (item?.id == deletedItem.id ||
|
||||
equalsNotNull(item?.data?.id, deletedItem.data.seriesId)
|
||||
) {
|
||||
pager?.refreshPagesAfter(position)
|
||||
}
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error refreshing after deleted item %s", itemId)
|
||||
showToast(context, "Error refreshing after item deleted")
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveLibraryDisplayInfo(
|
||||
|
|
@ -252,34 +303,32 @@ class CollectionFolderViewModel
|
|||
recursive: Boolean,
|
||||
filter: GetItemsFilter,
|
||||
useSeriesForPrimary: Boolean,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (resetState) {
|
||||
loading.value = DataLoadingState.Loading
|
||||
}
|
||||
backgroundLoading.value = LoadingState.Loading
|
||||
this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection
|
||||
this@CollectionFolderViewModel.filter.value = filter
|
||||
) = viewModelScope.launch(Dispatchers.IO) {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (resetState) {
|
||||
loading.value = DataLoadingState.Loading
|
||||
}
|
||||
try {
|
||||
val newPager =
|
||||
createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init()
|
||||
if (newPager.isNotEmpty()) newPager.getBlocking(0)
|
||||
withContext(Dispatchers.Main) {
|
||||
loading.value = DataLoadingState.Success(newPager)
|
||||
backgroundLoading.value = LoadingState.Success
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(
|
||||
ex,
|
||||
"Exception while loading data: sort=%s, filter=%s",
|
||||
sortAndDirection,
|
||||
filter,
|
||||
)
|
||||
withContext(Dispatchers.Main) {
|
||||
loading.value = DataLoadingState.Error(ex)
|
||||
}
|
||||
backgroundLoading.value = LoadingState.Loading
|
||||
this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection
|
||||
this@CollectionFolderViewModel.filter.value = filter
|
||||
}
|
||||
try {
|
||||
val newPager =
|
||||
createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init()
|
||||
if (newPager.isNotEmpty()) newPager.getBlocking(0)
|
||||
withContext(Dispatchers.Main) {
|
||||
loading.value = DataLoadingState.Success(newPager)
|
||||
backgroundLoading.value = LoadingState.Success
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(
|
||||
ex,
|
||||
"Exception while loading data: sort=%s, filter=%s",
|
||||
sortAndDirection,
|
||||
filter,
|
||||
)
|
||||
withContext(Dispatchers.Main) {
|
||||
loading.value = DataLoadingState.Error(ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -344,7 +393,12 @@ class CollectionFolderViewModel
|
|||
filter.applyTo(
|
||||
GetItemsRequest(
|
||||
parentId = item?.id,
|
||||
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
|
||||
enableImageTypes =
|
||||
listOf(
|
||||
ImageType.PRIMARY,
|
||||
ImageType.THUMB,
|
||||
ImageType.BACKDROP,
|
||||
),
|
||||
includeItemTypes = includeItemTypes,
|
||||
recursive = recursive,
|
||||
excludeItemIds = item?.let { listOf(item.id) },
|
||||
|
|
@ -436,6 +490,46 @@ class CollectionFolderViewModel
|
|||
backdropService.submit(item)
|
||||
}
|
||||
}
|
||||
|
||||
fun navigateTo(destination: Destination) {
|
||||
release()
|
||||
navigationManager.navigateTo(destination)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
themeSongPlayer.stop()
|
||||
}
|
||||
|
||||
fun onResumePage() {
|
||||
viewModelScope.launchIO {
|
||||
item.value?.let {
|
||||
Timber.v("onResumePage: %s", loading.value!!::class)
|
||||
if (it.type == BaseItemKind.BOX_SET && loading.value !is DataLoadingState.Error) {
|
||||
val volume =
|
||||
userPreferencesService
|
||||
.getCurrent()
|
||||
.appPreferences.interfacePreferences.playThemeSongs
|
||||
themeSongPlayer.playThemeFor(it.id, volume)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteItem(
|
||||
index: Int,
|
||||
item: BaseItem,
|
||||
) {
|
||||
deleteItem(context, mediaManagementService, item) {
|
||||
viewModelScope.launchDefault {
|
||||
refreshAfterDelete(index, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun canDelete(
|
||||
item: BaseItem,
|
||||
appPreferences: AppPreferences,
|
||||
): Boolean = mediaManagementService.canDelete(item, appPreferences)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -523,13 +617,14 @@ fun CollectionFolderGrid(
|
|||
|
||||
var moreDialog by remember { mutableStateOf<Optional<PositionItem>>(Optional.absent()) }
|
||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||
var showDeleteDialog by remember { mutableStateOf<PositionItem?>(null) }
|
||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
|
||||
when (val state = loading) {
|
||||
DataLoadingState.Loading,
|
||||
DataLoadingState.Pending,
|
||||
-> {
|
||||
LoadingPage()
|
||||
LoadingPage(modifier)
|
||||
}
|
||||
|
||||
is DataLoadingState.Error,
|
||||
|
|
@ -541,6 +636,13 @@ fun CollectionFolderGrid(
|
|||
?: item?.data?.collectionType?.name
|
||||
?: stringResource(R.string.collection)
|
||||
Box(modifier = modifier) {
|
||||
LifecycleResumeEffect(itemId) {
|
||||
viewModel.onResumePage()
|
||||
|
||||
onPauseOrDispose {
|
||||
viewModel.release()
|
||||
}
|
||||
}
|
||||
CollectionFolderGridContent(
|
||||
preferences = preferences,
|
||||
initialPosition = viewModel.position,
|
||||
|
|
@ -577,21 +679,45 @@ fun CollectionFolderGrid(
|
|||
onSaveViewOptions = { viewModel.saveViewOptions(it) },
|
||||
onChangeBackdrop = viewModel::updateBackdrop,
|
||||
playEnabled = playEnabled,
|
||||
onClickPlay = { _, item ->
|
||||
viewModel.navigationManager.navigateTo(Destination.Playback(item))
|
||||
onClickPlay = { index, item ->
|
||||
val destination =
|
||||
if (item.type == BaseItemKind.PHOTO_ALBUM) {
|
||||
Destination.Slideshow(
|
||||
parentId = item.id,
|
||||
index = index,
|
||||
filter = CollectionFolderFilter(filter = filter),
|
||||
sortAndDirection = sortAndDirection,
|
||||
recursive = true,
|
||||
startSlideshow = true,
|
||||
)
|
||||
} else {
|
||||
Destination.Playback(item)
|
||||
}
|
||||
viewModel.navigateTo(destination)
|
||||
},
|
||||
onClickPlayAll = { shuffle ->
|
||||
itemId.toUUIDOrNull()?.let {
|
||||
viewModel.navigationManager.navigateTo(
|
||||
Destination.PlaybackList(
|
||||
itemId = it,
|
||||
startIndex = 0,
|
||||
shuffle = shuffle,
|
||||
recursive = recursive,
|
||||
sortAndDirection = sortAndDirection,
|
||||
filter = filter,
|
||||
),
|
||||
)
|
||||
val destination =
|
||||
if (item?.type == BaseItemKind.PHOTO_ALBUM) {
|
||||
Destination.Slideshow(
|
||||
parentId = it,
|
||||
index = 0,
|
||||
filter = CollectionFolderFilter(filter = filter),
|
||||
sortAndDirection = sortAndDirection,
|
||||
recursive = true,
|
||||
startSlideshow = true,
|
||||
)
|
||||
} else {
|
||||
Destination.PlaybackList(
|
||||
itemId = it,
|
||||
startIndex = 0,
|
||||
shuffle = shuffle,
|
||||
recursive = recursive,
|
||||
sortAndDirection = sortAndDirection,
|
||||
filter = filter,
|
||||
)
|
||||
}
|
||||
viewModel.navigateTo(destination)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
@ -627,9 +753,10 @@ fun CollectionFolderGrid(
|
|||
playbackPosition = item.playbackPosition,
|
||||
watched = item.played,
|
||||
favorite = item.favorite,
|
||||
canDelete = viewModel.canDelete(item, preferences.appPreferences),
|
||||
actions =
|
||||
MoreDialogActions(
|
||||
navigateTo = { viewModel.navigationManager.navigateTo(it) },
|
||||
navigateTo = { viewModel.navigateTo(it) },
|
||||
onClickWatch = { itemId, watched ->
|
||||
viewModel.setWatched(position, itemId, watched)
|
||||
},
|
||||
|
|
@ -640,6 +767,10 @@ fun CollectionFolderGrid(
|
|||
playlistViewModel.loadPlaylists(MediaType.VIDEO)
|
||||
showPlaylistDialog.makePresent(it)
|
||||
},
|
||||
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
||||
onClickDelete = {
|
||||
showDeleteDialog = PositionItem(position, item)
|
||||
},
|
||||
),
|
||||
),
|
||||
onDismissRequest = { moreDialog.makeAbsent() },
|
||||
|
|
@ -664,8 +795,19 @@ fun CollectionFolderGrid(
|
|||
elevation = 3.dp,
|
||||
)
|
||||
}
|
||||
showDeleteDialog?.let { (position, item) ->
|
||||
ConfirmDeleteDialog(
|
||||
itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "),
|
||||
onCancel = { showDeleteDialog = null },
|
||||
onConfirm = {
|
||||
viewModel.deleteItem(position, item)
|
||||
showDeleteDialog = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun CollectionFolderGridContent(
|
||||
preferences: UserPreferences,
|
||||
|
|
@ -728,8 +870,8 @@ fun CollectionFolderGridContent(
|
|||
) {
|
||||
AnimatedVisibility(
|
||||
showHeader || loadingState !is DataLoadingState.Success,
|
||||
enter = slideInVertically() + fadeIn(),
|
||||
exit = slideOutVertically() + fadeOut(),
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
|
|
@ -810,14 +952,16 @@ fun CollectionFolderGridContent(
|
|||
}
|
||||
}
|
||||
}
|
||||
val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current
|
||||
val density = LocalDensity.current
|
||||
AnimatedVisibility(viewOptions.showDetails) {
|
||||
HomePageHeader(
|
||||
item = focusedItem,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(140.dp)
|
||||
.padding(16.dp),
|
||||
.height(200.dp)
|
||||
.padding(top = 48.dp, bottom = 32.dp, start = 8.dp),
|
||||
)
|
||||
}
|
||||
when (val state = loadingState) {
|
||||
|
|
@ -825,7 +969,7 @@ fun CollectionFolderGridContent(
|
|||
DataLoadingState.Loading,
|
||||
-> {
|
||||
// This shouldn't happen, so just show placeholder
|
||||
Text("Loading")
|
||||
Text(stringResource(R.string.loading))
|
||||
}
|
||||
|
||||
is DataLoadingState.Error -> {
|
||||
|
|
@ -863,6 +1007,15 @@ fun CollectionFolderGridContent(
|
|||
},
|
||||
columns = viewOptions.columns,
|
||||
spacing = viewOptions.spacing.dp,
|
||||
bringIntoViewSpec =
|
||||
remember(viewOptions) {
|
||||
val spacingPx = with(density) { viewOptions.spacing.dp.toPx() }
|
||||
if (viewOptions.showDetails) {
|
||||
ScrollToTopBringIntoViewSpec(spacingPx)
|
||||
} else {
|
||||
defaultBringIntoViewSpec
|
||||
}
|
||||
},
|
||||
)
|
||||
AnimatedVisibility(showViewOptions) {
|
||||
ViewOptionsDialog(
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import androidx.annotation.StringRes
|
|||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
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.BoxScope
|
||||
|
|
@ -17,11 +19,14 @@ import androidx.compose.foundation.layout.height
|
|||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
|
|
@ -30,9 +35,12 @@ import androidx.compose.runtime.mutableStateOf
|
|||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
|
@ -56,8 +64,12 @@ import androidx.tv.material3.surfaceColorAtElevation
|
|||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.playback.SimpleMediaStream
|
||||
import com.github.damontecres.wholphin.ui.playback.isDown
|
||||
import com.github.damontecres.wholphin.ui.playback.isUp
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -259,31 +271,65 @@ fun DialogPopupContent(
|
|||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
val scope = rememberCoroutineScope()
|
||||
val listState = rememberLazyListState()
|
||||
val focusRequesters = remember { List(dialogItems.size) { FocusRequester() } }
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier,
|
||||
) {
|
||||
items(dialogItems) {
|
||||
when (it) {
|
||||
itemsIndexed(dialogItems) { index, item ->
|
||||
when (item) {
|
||||
is DialogItemDivider -> {
|
||||
HorizontalDivider(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
is DialogItem -> {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
ListItem(
|
||||
selected = it.selected,
|
||||
enabled = !waiting && it.enabled,
|
||||
selected = item.selected,
|
||||
enabled = !waiting && item.enabled,
|
||||
onClick = {
|
||||
if (dismissOnClick) {
|
||||
onDismissRequest.invoke()
|
||||
}
|
||||
it.onClick.invoke()
|
||||
item.onClick.invoke()
|
||||
},
|
||||
headlineContent = it.headlineContent,
|
||||
overlineContent = it.overlineContent,
|
||||
supportingContent = it.supportingContent,
|
||||
leadingContent = it.leadingContent,
|
||||
trailingContent = it.trailingContent,
|
||||
modifier = Modifier,
|
||||
headlineContent = item.headlineContent,
|
||||
overlineContent = item.overlineContent,
|
||||
supportingContent = item.supportingContent,
|
||||
leadingContent = item.leadingContent,
|
||||
trailingContent = item.trailingContent,
|
||||
interactionSource = interactionSource,
|
||||
modifier =
|
||||
Modifier
|
||||
.focusRequester(focusRequesters[index])
|
||||
.ifElse(
|
||||
index == 0,
|
||||
Modifier.onKeyEvent {
|
||||
if (focused && isUp(it) && it.type == KeyEventType.KeyDown) {
|
||||
scope.launch {
|
||||
listState.animateScrollToItem(dialogItems.lastIndex)
|
||||
focusRequesters[dialogItems.lastIndex].tryRequestFocus()
|
||||
}
|
||||
return@onKeyEvent true
|
||||
}
|
||||
false
|
||||
},
|
||||
).ifElse(
|
||||
index == dialogItems.lastIndex,
|
||||
Modifier.onKeyEvent {
|
||||
if (focused && isDown(it) && it.type == KeyEventType.KeyDown) {
|
||||
scope.launch {
|
||||
listState.animateScrollToItem(0)
|
||||
focusRequesters[0].tryRequestFocus()
|
||||
}
|
||||
return@onKeyEvent true
|
||||
}
|
||||
false
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -408,12 +454,13 @@ fun ConfirmDialog(
|
|||
onConfirm: () -> Unit,
|
||||
properties: DialogProperties = DialogProperties(),
|
||||
elevation: Dp = 8.dp,
|
||||
bodyColor: Color = MaterialTheme.colorScheme.onSurface,
|
||||
) = BasicDialog(
|
||||
onDismissRequest = onCancel,
|
||||
properties = properties,
|
||||
elevation = elevation,
|
||||
content = {
|
||||
ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier)
|
||||
ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier, bodyColor)
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -427,6 +474,7 @@ fun ConfirmDialogContent(
|
|||
onCancel: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
bodyColor: Color = MaterialTheme.colorScheme.onSurface,
|
||||
) {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
|
|
@ -446,7 +494,7 @@ fun ConfirmDialogContent(
|
|||
item {
|
||||
Text(
|
||||
text = body,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
color = bodyColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -469,6 +517,80 @@ fun ConfirmDialogContent(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConfirmDeleteDialog(
|
||||
itemTitle: String,
|
||||
onCancel: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
) {
|
||||
BasicDialog(
|
||||
onDismissRequest = onCancel,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
LazyColumn(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
modifier = Modifier.wrapContentWidth(),
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.delete_item),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
text = itemTitle,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(32.dp),
|
||||
modifier = Modifier,
|
||||
) {
|
||||
Button(
|
||||
onClick = onCancel,
|
||||
modifier = Modifier.width(72.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.cancel),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = onConfirm,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 4.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = null,
|
||||
tint = Color.Red.copy(alpha = .8f),
|
||||
modifier = Modifier,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.confirm),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun chooseVersionParams(
|
||||
context: Context,
|
||||
sources: List<MediaSourceInfo>,
|
||||
|
|
@ -534,6 +656,9 @@ fun chooseStream(
|
|||
)
|
||||
add(
|
||||
DialogItem(
|
||||
leadingContent = {
|
||||
SelectedLeadingContent(currentIndex == TrackIndex.ONLY_FORCED)
|
||||
},
|
||||
headlineContent = {
|
||||
Text(text = stringResource(R.string.only_forced_subtitles))
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
package com.github.damontecres.wholphin.ui.components
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.NonRestartableComposable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
|
||||
/**
|
||||
* Placeholder for [com.github.damontecres.wholphin.ui.cards.ItemRow]. It is [focusable] so it can be scrolled.
|
||||
*/
|
||||
@Composable
|
||||
@NonRestartableComposable
|
||||
fun FocusableItemRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
modifier: Modifier = Modifier,
|
||||
isError: Boolean = false,
|
||||
) = FocusableItemRow(
|
||||
titleContent = {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
},
|
||||
subtitleContent = {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun FocusableItemRow(
|
||||
titleContent: @Composable () -> Unit,
|
||||
subtitleContent: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
val background by animateColorAsState(
|
||||
if (focused) {
|
||||
MaterialTheme.colorScheme.border.copy(alpha = .25f)
|
||||
} else {
|
||||
Color.Unspecified
|
||||
},
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier =
|
||||
modifier
|
||||
.padding(start = 8.dp)
|
||||
.focusable(interactionSource = interactionSource)
|
||||
.background(background, shape = RoundedCornerShape(8.dp))
|
||||
.padding(8.dp),
|
||||
) {
|
||||
titleContent.invoke()
|
||||
subtitleContent.invoke()
|
||||
}
|
||||
}
|
||||
|
|
@ -5,12 +5,12 @@ import androidx.compose.foundation.layout.Box
|
|||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -21,8 +21,7 @@ import androidx.lifecycle.ViewModel
|
|||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
||||
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||
import com.github.damontecres.wholphin.data.model.createGenreDestination
|
||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
||||
|
|
@ -30,17 +29,18 @@ import com.github.damontecres.wholphin.ui.SlimItemFields
|
|||
import com.github.damontecres.wholphin.ui.cards.GenreCard
|
||||
import com.github.damontecres.wholphin.ui.detail.CardGrid
|
||||
import com.github.damontecres.wholphin.ui.detail.CardGridItem
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import com.mayakapps.kache.InMemoryKache
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
|
|
@ -56,8 +56,10 @@ import org.jellyfin.sdk.model.api.ItemFields
|
|||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.request.GetGenresRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
|
||||
@HiltViewModel(assistedFactory = GenreViewModel.Factory::class)
|
||||
class GenreViewModel
|
||||
|
|
@ -102,51 +104,23 @@ class GenreViewModel
|
|||
.execute(api, request)
|
||||
.content.items
|
||||
.map {
|
||||
Genre(it.id, it.name ?: "", null, Color.Black)
|
||||
Genre(it.id, it.name ?: "", null)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
this@GenreViewModel.genres.value = genres
|
||||
loading.value = LoadingState.Success
|
||||
}
|
||||
val genreToUrl = ConcurrentHashMap<UUID, String?>()
|
||||
val semaphore = Semaphore(4)
|
||||
genres
|
||||
.map { genre ->
|
||||
viewModelScope.async(Dispatchers.IO) {
|
||||
semaphore.withPermit {
|
||||
val item =
|
||||
GetItemsRequestHandler
|
||||
.execute(
|
||||
api,
|
||||
GetItemsRequest(
|
||||
parentId = itemId,
|
||||
recursive = true,
|
||||
limit = 1,
|
||||
sortBy = listOf(ItemSortBy.RANDOM),
|
||||
fields = listOf(ItemFields.GENRES),
|
||||
imageTypes = listOf(ImageType.BACKDROP),
|
||||
imageTypeLimit = 1,
|
||||
includeItemTypes = includeItemTypes,
|
||||
genreIds = listOf(genre.id),
|
||||
enableTotalRecordCount = false,
|
||||
),
|
||||
).content.items
|
||||
.firstOrNull()
|
||||
if (item != null) {
|
||||
genreToUrl[genre.id] =
|
||||
imageUrlService.getItemImageUrl(
|
||||
itemId = item.id,
|
||||
itemType = item.type,
|
||||
seriesId = null,
|
||||
useSeriesForPrimary = true,
|
||||
imageType = ImageType.BACKDROP,
|
||||
imageTags = item.imageTags.orEmpty(),
|
||||
fillWidth = cardWidthPx,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
val genreToUrl =
|
||||
getGenreImageMap(
|
||||
api = api,
|
||||
userId = serverRepository.currentUser.value?.id,
|
||||
scope = viewModelScope,
|
||||
imageUrlService = imageUrlService,
|
||||
genres = genres.map { it.id },
|
||||
parentId = itemId,
|
||||
includeItemTypes = includeItemTypes,
|
||||
cardWidthPx = cardWidthPx,
|
||||
)
|
||||
val genresWithImages =
|
||||
genres.map {
|
||||
it.copy(
|
||||
|
|
@ -171,11 +145,85 @@ class GenreViewModel
|
|||
}
|
||||
}
|
||||
|
||||
data class GenreCacheKey(
|
||||
val userId: UUID?,
|
||||
val parentId: UUID,
|
||||
)
|
||||
|
||||
private val genreCache by lazy {
|
||||
InMemoryKache<GenreCacheKey, Map<UUID, String?>>(8) {
|
||||
expireAfterWriteDuration = 2.hours
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getGenreImageMap(
|
||||
api: ApiClient,
|
||||
userId: UUID?,
|
||||
scope: CoroutineScope,
|
||||
imageUrlService: ImageUrlService,
|
||||
genres: List<UUID>,
|
||||
parentId: UUID,
|
||||
includeItemTypes: List<BaseItemKind>?,
|
||||
cardWidthPx: Int?,
|
||||
useCache: Boolean = true,
|
||||
): Map<UUID, String?> {
|
||||
val key = GenreCacheKey(userId, parentId)
|
||||
if (useCache) {
|
||||
genreCache.getIfAvailable(key)?.let {
|
||||
Timber.v("Got cached entry")
|
||||
return it
|
||||
}
|
||||
}
|
||||
val genreToUrl = ConcurrentHashMap<UUID, String?>()
|
||||
val semaphore = Semaphore(4)
|
||||
genres
|
||||
.map { genreId ->
|
||||
scope.async(Dispatchers.IO) {
|
||||
semaphore.withPermit {
|
||||
val item =
|
||||
GetItemsRequestHandler
|
||||
.execute(
|
||||
api,
|
||||
GetItemsRequest(
|
||||
userId = userId,
|
||||
parentId = parentId,
|
||||
recursive = true,
|
||||
limit = 1,
|
||||
sortBy = listOf(ItemSortBy.RANDOM),
|
||||
fields = listOf(ItemFields.GENRES),
|
||||
imageTypes = listOf(ImageType.BACKDROP),
|
||||
imageTypeLimit = 1,
|
||||
includeItemTypes = includeItemTypes,
|
||||
genreIds = listOf(genreId),
|
||||
enableTotalRecordCount = false,
|
||||
),
|
||||
).content.items
|
||||
.firstOrNull()
|
||||
if (item != null) {
|
||||
genreToUrl[genreId] =
|
||||
imageUrlService.getItemImageUrl(
|
||||
itemId = item.id,
|
||||
itemType = item.type,
|
||||
seriesId = null,
|
||||
useSeriesForPrimary = true,
|
||||
imageType = ImageType.BACKDROP,
|
||||
imageTags = item.imageTags.orEmpty(),
|
||||
fillWidth = cardWidthPx,
|
||||
backdropTags = item.backdropImageTags.orEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
genreCache.put(key, genreToUrl)
|
||||
return genreToUrl
|
||||
}
|
||||
|
||||
@Stable
|
||||
data class Genre(
|
||||
val id: UUID,
|
||||
val name: String,
|
||||
val imageUrl: String?,
|
||||
val color: Color,
|
||||
) : CardGridItem {
|
||||
override val gridId: String get() = id.toString()
|
||||
override val playable: Boolean = false
|
||||
|
|
@ -233,23 +281,12 @@ fun GenreCardGrid(
|
|||
pager = genres,
|
||||
onClickItem = { _, genre ->
|
||||
viewModel.navigationManager.navigateTo(
|
||||
Destination.FilteredCollection(
|
||||
itemId = itemId,
|
||||
filter =
|
||||
CollectionFolderFilter(
|
||||
nameOverride =
|
||||
listOfNotNull(
|
||||
genre.name,
|
||||
item?.title,
|
||||
).joinToString(" "),
|
||||
filter =
|
||||
GetItemsFilter(
|
||||
genres = listOf(genre.id),
|
||||
includeItemTypes = includeItemTypes,
|
||||
),
|
||||
useSavedLibraryDisplayInfo = false,
|
||||
),
|
||||
recursive = true,
|
||||
createGenreDestination(
|
||||
genreId = genre.id,
|
||||
genreName = genre.name,
|
||||
parentId = itemId,
|
||||
parentName = item?.title,
|
||||
includeItemTypes = includeItemTypes,
|
||||
),
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -95,13 +95,13 @@ fun ItemGrid(
|
|||
val items by viewModel.items.observeAsState(listOf())
|
||||
when (val state = loading) {
|
||||
is LoadingState.Error -> {
|
||||
ErrorMessage(state)
|
||||
ErrorMessage(state, modifier)
|
||||
}
|
||||
|
||||
LoadingState.Loading,
|
||||
LoadingState.Pending,
|
||||
-> {
|
||||
LoadingPage()
|
||||
LoadingPage(modifier)
|
||||
}
|
||||
|
||||
LoadingState.Success -> {
|
||||
|
|
|
|||
|
|
@ -2,11 +2,15 @@ package com.github.damontecres.wholphin.ui.components
|
|||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.focusGroup
|
||||
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.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
|
|
@ -17,6 +21,7 @@ import androidx.compose.foundation.layout.requiredSizeIn
|
|||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
|
|
@ -34,12 +39,16 @@ import androidx.compose.ui.focus.focusRestorer
|
|||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.LocalContentColor
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
|
|
@ -63,12 +72,14 @@ fun ExpandablePlayButtons(
|
|||
resumePosition: Duration,
|
||||
watched: Boolean,
|
||||
favorite: Boolean,
|
||||
canDelete: Boolean,
|
||||
trailers: List<Trailer>?,
|
||||
playOnClick: (position: Duration) -> Unit,
|
||||
watchOnClick: () -> Unit,
|
||||
favoriteOnClick: () -> Unit,
|
||||
moreOnClick: () -> Unit,
|
||||
trailerOnClick: (Trailer) -> Unit,
|
||||
deleteOnClick: () -> Unit,
|
||||
buttonOnFocusChanged: (FocusState) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
|
|
@ -149,6 +160,16 @@ fun ExpandablePlayButtons(
|
|||
)
|
||||
}
|
||||
}
|
||||
if (canDelete) {
|
||||
item("delete") {
|
||||
DeleteButton(
|
||||
onClick = deleteOnClick,
|
||||
modifier =
|
||||
Modifier
|
||||
.onFocusChanged(buttonOnFocusChanged),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// More button
|
||||
item("more") {
|
||||
|
|
@ -180,6 +201,63 @@ fun ExpandablePlayButton(
|
|||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
mirrorIcon: Boolean = false,
|
||||
enabled: Boolean = true,
|
||||
) = ExpandablePlayButton(
|
||||
title = title,
|
||||
resume = resume,
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(28.dp)
|
||||
.ifElse(mirrorIcon, Modifier.graphicsLayer { scaleX = -1f }),
|
||||
)
|
||||
},
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
interactionSource = interactionSource,
|
||||
enabled = enabled,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ExpandablePlayButton(
|
||||
@StringRes title: Int,
|
||||
resume: Duration,
|
||||
icon: Painter,
|
||||
onClick: (position: Duration) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
mirrorIcon: Boolean = false,
|
||||
enabled: Boolean = true,
|
||||
) = ExpandablePlayButton(
|
||||
title = title,
|
||||
resume = resume,
|
||||
icon = {
|
||||
Icon(
|
||||
painter = icon,
|
||||
contentDescription = null,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(28.dp)
|
||||
.ifElse(mirrorIcon, Modifier.graphicsLayer { scaleX = -1f }),
|
||||
)
|
||||
},
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
interactionSource = interactionSource,
|
||||
enabled = enabled,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ExpandablePlayButton(
|
||||
@StringRes title: Int,
|
||||
resume: Duration,
|
||||
icon: @Composable BoxScope.() -> Unit,
|
||||
onClick: (position: Duration) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
val isFocused = interactionSource.collectIsFocusedAsState().value
|
||||
Button(
|
||||
|
|
@ -195,19 +273,13 @@ fun ExpandablePlayButton(
|
|||
interactionSource = interactionSource,
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 2.dp, top = 2.dp)
|
||||
.padding(start = 2.dp)
|
||||
.height(MinButtonSize),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(28.dp)
|
||||
.ifElse(mirrorIcon, Modifier.graphicsLayer { scaleX = -1f }),
|
||||
)
|
||||
icon.invoke(this)
|
||||
}
|
||||
AnimatedVisibility(isFocused) {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
|
@ -307,6 +379,48 @@ fun TrailerButton(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
val iconTint by
|
||||
animateColorAsState(
|
||||
targetValue =
|
||||
if (focused) {
|
||||
Color.Red.copy(alpha = .8f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(
|
||||
alpha = 0.8f,
|
||||
)
|
||||
},
|
||||
animationSpec =
|
||||
tween(
|
||||
easing = LinearEasing,
|
||||
),
|
||||
)
|
||||
ExpandablePlayButton(
|
||||
title = R.string.delete,
|
||||
resume = Duration.ZERO,
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = null,
|
||||
tint = if (iconTint.isSpecified) iconTint else LocalContentColor.current,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 2.dp)
|
||||
.size(24.dp),
|
||||
)
|
||||
},
|
||||
onClick = { onClick.invoke() },
|
||||
interactionSource = interactionSource,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewTvSpec
|
||||
@Composable
|
||||
private fun ExpandablePlayButtonsPreview() {
|
||||
|
|
@ -322,6 +436,8 @@ private fun ExpandablePlayButtonsPreview() {
|
|||
buttonOnFocusChanged = {},
|
||||
trailers = listOf(),
|
||||
trailerOnClick = {},
|
||||
canDelete = true,
|
||||
deleteOnClick = {},
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
|
|
@ -343,6 +459,13 @@ private fun ViewOptionsPreview() {
|
|||
onClick = {},
|
||||
interactionSource = source,
|
||||
)
|
||||
ExpandablePlayButton(
|
||||
title = R.string.play,
|
||||
resume = Duration.ZERO,
|
||||
icon = painterResource(R.drawable.baseline_pause_24),
|
||||
onClick = {},
|
||||
interactionSource = source,
|
||||
)
|
||||
ExpandableFaButton(
|
||||
title = R.string.play,
|
||||
iconStringRes = R.string.fa_eye,
|
||||
|
|
@ -358,12 +481,19 @@ private fun ViewOptionsPreview() {
|
|||
onClick = {},
|
||||
modifier = Modifier,
|
||||
)
|
||||
DeleteButton(
|
||||
onClick = {},
|
||||
)
|
||||
SortByButton(
|
||||
sortOptions = listOf(),
|
||||
current = SortAndDirection(ItemSortBy.DEFAULT, SortOrder.ASCENDING),
|
||||
onSortChange = {},
|
||||
)
|
||||
}
|
||||
DeleteButton(
|
||||
onClick = {},
|
||||
interactionSource = source,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,10 +19,14 @@ import androidx.lifecycle.ViewModel
|
|||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.MediaManagementService
|
||||
import com.github.damontecres.wholphin.services.MediaReportService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.deleteItem
|
||||
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
||||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||
|
|
@ -30,15 +34,18 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
|
|||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.main.HomePageContent
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.rememberPosition
|
||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import java.util.UUID
|
||||
|
||||
|
|
@ -46,7 +53,9 @@ abstract class RecommendedViewModel(
|
|||
val context: Context,
|
||||
val navigationManager: NavigationManager,
|
||||
val favoriteWatchManager: FavoriteWatchManager,
|
||||
val mediaReportService: MediaReportService,
|
||||
private val backdropService: BackdropService,
|
||||
private val mediaManagementService: MediaManagementService,
|
||||
) : ViewModel() {
|
||||
abstract fun init()
|
||||
|
||||
|
|
@ -97,13 +106,13 @@ abstract class RecommendedViewModel(
|
|||
abstract fun update(
|
||||
@StringRes title: Int,
|
||||
row: HomeRowLoadingState,
|
||||
)
|
||||
): HomeRowLoadingState
|
||||
|
||||
fun update(
|
||||
@StringRes title: Int,
|
||||
block: suspend () -> List<BaseItem>,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
): Deferred<HomeRowLoadingState> =
|
||||
viewModelScope.async(Dispatchers.IO) {
|
||||
val titleStr = context.getString(title)
|
||||
val row =
|
||||
try {
|
||||
|
|
@ -113,7 +122,25 @@ abstract class RecommendedViewModel(
|
|||
}
|
||||
update(title, row)
|
||||
}
|
||||
|
||||
fun deleteItem(
|
||||
position: RowColumn,
|
||||
item: BaseItem,
|
||||
) {
|
||||
deleteItem(context, mediaManagementService, item) {
|
||||
viewModelScope.launchDefault {
|
||||
val row = rows.value.getOrNull(position.row)
|
||||
if (row is HomeRowLoadingState.Success) {
|
||||
(row.items as? ApiRequestPager<*>)?.refreshPagesAfter(position.column)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun canDelete(
|
||||
item: BaseItem,
|
||||
appPreferences: AppPreferences,
|
||||
): Boolean = mediaManagementService.canDelete(item, appPreferences)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -127,6 +154,7 @@ fun RecommendedContent(
|
|||
val context = LocalContext.current
|
||||
var moreDialog by remember { mutableStateOf<Optional<RowColumnItem>>(Optional.absent()) }
|
||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||
var showDeleteDialog by remember { mutableStateOf<RowColumnItem?>(null) }
|
||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
|
||||
OneTimeLaunchedEffect {
|
||||
|
|
@ -137,18 +165,20 @@ fun RecommendedContent(
|
|||
|
||||
when (val state = loading) {
|
||||
is LoadingState.Error -> {
|
||||
ErrorMessage(state)
|
||||
ErrorMessage(state, modifier)
|
||||
}
|
||||
|
||||
LoadingState.Loading,
|
||||
LoadingState.Pending,
|
||||
-> {
|
||||
LoadingPage()
|
||||
LoadingPage(modifier)
|
||||
}
|
||||
|
||||
LoadingState.Success -> {
|
||||
var position by rememberPosition()
|
||||
HomePageContent(
|
||||
homeRows = rows,
|
||||
position = position,
|
||||
onClickItem = { _, item ->
|
||||
viewModel.navigationManager.navigateTo(item.destination())
|
||||
},
|
||||
|
|
@ -158,7 +188,21 @@ fun RecommendedContent(
|
|||
onClickPlay = { _, item ->
|
||||
viewModel.navigationManager.navigateTo(Destination.Playback(item))
|
||||
},
|
||||
onFocusPosition = onFocusPosition,
|
||||
onFocusPosition = {
|
||||
position = it
|
||||
val nonEmptyRowBefore =
|
||||
rows
|
||||
.subList(0, it.row)
|
||||
.count {
|
||||
it is HomeRowLoadingState.Success && it.items.isEmpty()
|
||||
}
|
||||
onFocusPosition?.invoke(
|
||||
RowColumn(
|
||||
it.row - nonEmptyRowBefore,
|
||||
it.column,
|
||||
),
|
||||
)
|
||||
},
|
||||
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
||||
onUpdateBackdrop = viewModel::updateBackdrop,
|
||||
modifier = modifier,
|
||||
|
|
@ -177,6 +221,7 @@ fun RecommendedContent(
|
|||
playbackPosition = item.playbackPosition,
|
||||
watched = item.played,
|
||||
favorite = item.favorite,
|
||||
canDelete = viewModel.canDelete(item, preferences.appPreferences),
|
||||
actions =
|
||||
MoreDialogActions(
|
||||
navigateTo = { viewModel.navigationManager.navigateTo(it) },
|
||||
|
|
@ -190,6 +235,8 @@ fun RecommendedContent(
|
|||
playlistViewModel.loadPlaylists(MediaType.VIDEO)
|
||||
showPlaylistDialog.makePresent(it)
|
||||
},
|
||||
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
||||
onClickDelete = { showDeleteDialog = RowColumnItem(position, item) },
|
||||
),
|
||||
),
|
||||
onDismissRequest = { moreDialog.makeAbsent() },
|
||||
|
|
@ -214,9 +261,19 @@ fun RecommendedContent(
|
|||
elevation = 3.dp,
|
||||
)
|
||||
}
|
||||
showDeleteDialog?.let { (position, item) ->
|
||||
ConfirmDeleteDialog(
|
||||
itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "),
|
||||
onCancel = { showDeleteDialog = null },
|
||||
onConfirm = {
|
||||
viewModel.deleteItem(position, item)
|
||||
showDeleteDialog = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class RowColumnItem(
|
||||
data class RowColumnItem(
|
||||
val position: RowColumn,
|
||||
val item: BaseItem,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,11 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
|
|||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.MediaManagementService
|
||||
import com.github.damontecres.wholphin.services.MediaReportService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.SuggestionService
|
||||
import com.github.damontecres.wholphin.services.SuggestionsResource
|
||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
|
|
@ -22,7 +26,6 @@ import com.github.damontecres.wholphin.ui.toBaseItems
|
|||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetSuggestionsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import dagger.assisted.Assisted
|
||||
|
|
@ -30,6 +33,8 @@ import dagger.assisted.AssistedFactory
|
|||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
|
|
@ -42,7 +47,6 @@ import org.jellyfin.sdk.model.api.ItemSortBy
|
|||
import org.jellyfin.sdk.model.api.SortOrder
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
|
||||
|
|
@ -54,11 +58,21 @@ class RecommendedMovieViewModel
|
|||
private val api: ApiClient,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val preferencesDataStore: DataStore<AppPreferences>,
|
||||
private val suggestionService: SuggestionService,
|
||||
@Assisted val parentId: UUID,
|
||||
navigationManager: NavigationManager,
|
||||
favoriteWatchManager: FavoriteWatchManager,
|
||||
mediaReportService: MediaReportService,
|
||||
backdropService: BackdropService,
|
||||
) : RecommendedViewModel(context, navigationManager, favoriteWatchManager, backdropService) {
|
||||
mediaManagementService: MediaManagementService,
|
||||
) : RecommendedViewModel(
|
||||
context,
|
||||
navigationManager,
|
||||
favoriteWatchManager,
|
||||
mediaReportService,
|
||||
backdropService,
|
||||
mediaManagementService,
|
||||
) {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(parentId: UUID): RecommendedMovieViewModel
|
||||
|
|
@ -114,8 +128,10 @@ class RecommendedMovieViewModel
|
|||
}
|
||||
}
|
||||
|
||||
val jobs = mutableListOf<Deferred<HomeRowLoadingState>>()
|
||||
|
||||
update(R.string.recently_released) {
|
||||
val recentlyReleasedRequest =
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
parentId = parentId,
|
||||
fields = SlimItemFields,
|
||||
|
|
@ -128,13 +144,11 @@ class RecommendedMovieViewModel
|
|||
limit = itemsPerRow,
|
||||
enableTotalRecordCount = false,
|
||||
)
|
||||
GetItemsRequestHandler
|
||||
.execute(api, recentlyReleasedRequest)
|
||||
.toBaseItems(api, false)
|
||||
}
|
||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
|
||||
}.also(jobs::add)
|
||||
|
||||
update(R.string.recently_added) {
|
||||
val recentlyAddedRequest =
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
parentId = parentId,
|
||||
fields = SlimItemFields,
|
||||
|
|
@ -147,27 +161,11 @@ class RecommendedMovieViewModel
|
|||
limit = itemsPerRow,
|
||||
enableTotalRecordCount = false,
|
||||
)
|
||||
GetItemsRequestHandler
|
||||
.execute(api, recentlyAddedRequest)
|
||||
.toBaseItems(api, false)
|
||||
}
|
||||
|
||||
update(R.string.suggestions) {
|
||||
val suggestionsRequest =
|
||||
GetSuggestionsRequest(
|
||||
userId = serverRepository.currentUser.value?.id,
|
||||
type = listOf(BaseItemKind.MOVIE),
|
||||
startIndex = 0,
|
||||
limit = itemsPerRow,
|
||||
enableTotalRecordCount = false,
|
||||
)
|
||||
GetSuggestionsRequestHandler
|
||||
.execute(api, suggestionsRequest)
|
||||
.toBaseItems(api, false)
|
||||
}
|
||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
|
||||
}.also(jobs::add)
|
||||
|
||||
update(R.string.top_unwatched) {
|
||||
val unwatchedTopRatedRequest =
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
parentId = parentId,
|
||||
fields = SlimItemFields,
|
||||
|
|
@ -181,13 +179,63 @@ class RecommendedMovieViewModel
|
|||
limit = itemsPerRow,
|
||||
enableTotalRecordCount = false,
|
||||
)
|
||||
GetItemsRequestHandler
|
||||
.execute(api, unwatchedTopRatedRequest)
|
||||
.toBaseItems(api, false)
|
||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
|
||||
}.also(jobs::add)
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
suggestionService
|
||||
.getSuggestionsFlow(parentId, BaseItemKind.MOVIE)
|
||||
.collect { resource ->
|
||||
val state =
|
||||
when (resource) {
|
||||
is SuggestionsResource.Loading -> {
|
||||
HomeRowLoadingState.Loading(
|
||||
context.getString(R.string.suggestions),
|
||||
)
|
||||
}
|
||||
|
||||
is SuggestionsResource.Success -> {
|
||||
HomeRowLoadingState.Success(
|
||||
context.getString(R.string.suggestions),
|
||||
resource.items,
|
||||
)
|
||||
}
|
||||
|
||||
is SuggestionsResource.Empty -> {
|
||||
HomeRowLoadingState.Success(
|
||||
context.getString(R.string.suggestions),
|
||||
emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
update(R.string.suggestions, state)
|
||||
}
|
||||
} catch (ex: CancellationException) {
|
||||
throw ex
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Failed to fetch suggestions")
|
||||
update(
|
||||
R.string.suggestions,
|
||||
HomeRowLoadingState.Error(
|
||||
title = context.getString(R.string.suggestions),
|
||||
exception = ex,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// If the continue watching row is empty, then wait until the first successful row
|
||||
// is loaded before telling the UI that the page is loaded
|
||||
if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) {
|
||||
loading.setValueOnMain(LoadingState.Success)
|
||||
for (i in 0..<jobs.size) {
|
||||
val result = jobs[i].await()
|
||||
if (result.completed) {
|
||||
Timber.v("First success")
|
||||
loading.setValueOnMain(LoadingState.Success)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -195,10 +243,11 @@ class RecommendedMovieViewModel
|
|||
override fun update(
|
||||
@StringRes title: Int,
|
||||
row: HomeRowLoadingState,
|
||||
) {
|
||||
): HomeRowLoadingState {
|
||||
rows.update { current ->
|
||||
current.toMutableList().apply { set(rowTitles[title]!!, row) }
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,11 @@ import com.github.damontecres.wholphin.preferences.UserPreferences
|
|||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.LatestNextUpService
|
||||
import com.github.damontecres.wholphin.services.MediaManagementService
|
||||
import com.github.damontecres.wholphin.services.MediaReportService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.SuggestionService
|
||||
import com.github.damontecres.wholphin.services.SuggestionsResource
|
||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
|
|
@ -23,7 +27,6 @@ import com.github.damontecres.wholphin.util.ExceptionHandler
|
|||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetNextUpRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetSuggestionsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import dagger.assisted.Assisted
|
||||
|
|
@ -31,6 +34,8 @@ import dagger.assisted.AssistedFactory
|
|||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
|
@ -45,7 +50,6 @@ import org.jellyfin.sdk.model.api.SortOrder
|
|||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
|
||||
|
|
@ -58,11 +62,21 @@ class RecommendedTvShowViewModel
|
|||
private val serverRepository: ServerRepository,
|
||||
private val preferencesDataStore: DataStore<AppPreferences>,
|
||||
private val lastestNextUpService: LatestNextUpService,
|
||||
private val suggestionService: SuggestionService,
|
||||
@Assisted val parentId: UUID,
|
||||
navigationManager: NavigationManager,
|
||||
favoriteWatchManager: FavoriteWatchManager,
|
||||
mediaReportService: MediaReportService,
|
||||
backdropService: BackdropService,
|
||||
) : RecommendedViewModel(context, navigationManager, favoriteWatchManager, backdropService) {
|
||||
mediaManagementService: MediaManagementService,
|
||||
) : RecommendedViewModel(
|
||||
context,
|
||||
navigationManager,
|
||||
favoriteWatchManager,
|
||||
mediaReportService,
|
||||
backdropService,
|
||||
mediaManagementService,
|
||||
) {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(parentId: UUID): RecommendedTvShowViewModel
|
||||
|
|
@ -86,7 +100,7 @@ class RecommendedTvShowViewModel
|
|||
val userId = serverRepository.currentUser.value?.id
|
||||
try {
|
||||
val resumeItemsDeferred =
|
||||
viewModelScope.async(Dispatchers.IO) {
|
||||
async(Dispatchers.IO) {
|
||||
val resumeItemsRequest =
|
||||
GetResumeItemsRequest(
|
||||
userId = userId,
|
||||
|
|
@ -104,7 +118,7 @@ class RecommendedTvShowViewModel
|
|||
}
|
||||
|
||||
val nextUpItemsDeferred =
|
||||
viewModelScope.async(Dispatchers.IO) {
|
||||
async(Dispatchers.IO) {
|
||||
val nextUpRequest =
|
||||
GetNextUpRequest(
|
||||
userId = userId,
|
||||
|
|
@ -116,19 +130,16 @@ class RecommendedTvShowViewModel
|
|||
enableUserData = true,
|
||||
enableRewatching = preferences.homePagePreferences.enableRewatchingNextUp,
|
||||
)
|
||||
|
||||
GetNextUpRequestHandler
|
||||
.execute(api, nextUpRequest)
|
||||
.toBaseItems(api, true)
|
||||
}
|
||||
|
||||
val resumeItems = resumeItemsDeferred.await()
|
||||
val nextUpItems = nextUpItemsDeferred.await()
|
||||
|
||||
if (combineNextUp) {
|
||||
val combined =
|
||||
lastestNextUpService.buildCombined(
|
||||
resumeItems,
|
||||
nextUpItems,
|
||||
)
|
||||
val combined = lastestNextUpService.buildCombined(resumeItems, nextUpItems)
|
||||
update(
|
||||
R.string.continue_watching,
|
||||
HomeRowLoadingState.Success(
|
||||
|
|
@ -138,10 +149,7 @@ class RecommendedTvShowViewModel
|
|||
)
|
||||
update(
|
||||
R.string.next_up,
|
||||
HomeRowLoadingState.Success(
|
||||
context.getString(R.string.next_up),
|
||||
listOf(),
|
||||
),
|
||||
HomeRowLoadingState.Success(context.getString(R.string.next_up), listOf()),
|
||||
)
|
||||
} else {
|
||||
update(
|
||||
|
|
@ -153,10 +161,7 @@ class RecommendedTvShowViewModel
|
|||
)
|
||||
update(
|
||||
R.string.next_up,
|
||||
HomeRowLoadingState.Success(
|
||||
context.getString(R.string.next_up),
|
||||
nextUpItems,
|
||||
),
|
||||
HomeRowLoadingState.Success(context.getString(R.string.next_up), nextUpItems),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -170,8 +175,10 @@ class RecommendedTvShowViewModel
|
|||
}
|
||||
}
|
||||
|
||||
val jobs = mutableListOf<Deferred<HomeRowLoadingState>>()
|
||||
|
||||
update(R.string.recently_released) {
|
||||
val recentlyReleasedRequest =
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
parentId = parentId,
|
||||
fields = SlimItemFields,
|
||||
|
|
@ -184,14 +191,11 @@ class RecommendedTvShowViewModel
|
|||
limit = itemsPerRow,
|
||||
enableTotalRecordCount = false,
|
||||
)
|
||||
|
||||
GetItemsRequestHandler
|
||||
.execute(api, recentlyReleasedRequest)
|
||||
.toBaseItems(api, true)
|
||||
}
|
||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, true)
|
||||
}.also(jobs::add)
|
||||
|
||||
update(R.string.recently_added) {
|
||||
val recentlyAddedRequest =
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
parentId = parentId,
|
||||
fields = SlimItemFields,
|
||||
|
|
@ -204,29 +208,11 @@ class RecommendedTvShowViewModel
|
|||
limit = itemsPerRow,
|
||||
enableTotalRecordCount = false,
|
||||
)
|
||||
|
||||
GetItemsRequestHandler
|
||||
.execute(api, recentlyAddedRequest)
|
||||
.toBaseItems(api, true)
|
||||
}
|
||||
|
||||
update(R.string.suggestions) {
|
||||
val suggestionsRequest =
|
||||
GetSuggestionsRequest(
|
||||
userId = serverRepository.currentUser.value?.id,
|
||||
type = listOf(BaseItemKind.SERIES),
|
||||
startIndex = 0,
|
||||
limit = itemsPerRow,
|
||||
enableTotalRecordCount = false,
|
||||
)
|
||||
|
||||
GetSuggestionsRequestHandler
|
||||
.execute(api, suggestionsRequest)
|
||||
.toBaseItems(api, true)
|
||||
}
|
||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, true)
|
||||
}.also(jobs::add)
|
||||
|
||||
update(R.string.top_unwatched) {
|
||||
val unwatchedTopRatedRequest =
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
parentId = parentId,
|
||||
fields = SlimItemFields,
|
||||
|
|
@ -240,13 +226,61 @@ class RecommendedTvShowViewModel
|
|||
limit = itemsPerRow,
|
||||
enableTotalRecordCount = false,
|
||||
)
|
||||
GetItemsRequestHandler
|
||||
.execute(api, unwatchedTopRatedRequest)
|
||||
.toBaseItems(api, true)
|
||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, true)
|
||||
}.also(jobs::add)
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
suggestionService
|
||||
.getSuggestionsFlow(parentId, BaseItemKind.SERIES)
|
||||
.collect { resource ->
|
||||
val state =
|
||||
when (resource) {
|
||||
is SuggestionsResource.Loading -> {
|
||||
HomeRowLoadingState.Loading(
|
||||
context.getString(R.string.suggestions),
|
||||
)
|
||||
}
|
||||
|
||||
is SuggestionsResource.Success -> {
|
||||
HomeRowLoadingState.Success(
|
||||
context.getString(R.string.suggestions),
|
||||
resource.items,
|
||||
)
|
||||
}
|
||||
|
||||
is SuggestionsResource.Empty -> {
|
||||
HomeRowLoadingState.Success(
|
||||
context.getString(R.string.suggestions),
|
||||
emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
update(R.string.suggestions, state)
|
||||
}
|
||||
} catch (ex: CancellationException) {
|
||||
throw ex
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Failed to fetch suggestions")
|
||||
update(
|
||||
R.string.suggestions,
|
||||
HomeRowLoadingState.Error(
|
||||
title = context.getString(R.string.suggestions),
|
||||
exception = ex,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) {
|
||||
loading.setValueOnMain(LoadingState.Success)
|
||||
for (i in 0..<jobs.size) {
|
||||
val result = jobs[i].await()
|
||||
if (result is HomeRowLoadingState.Success) {
|
||||
Timber.v("First success")
|
||||
loading.setValueOnMain(LoadingState.Success)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -254,10 +288,11 @@ class RecommendedTvShowViewModel
|
|||
override fun update(
|
||||
@StringRes title: Int,
|
||||
row: HomeRowLoadingState,
|
||||
) {
|
||||
): HomeRowLoadingState {
|
||||
rows.update { current ->
|
||||
current.toMutableList().apply { set(rowTitles[title]!!, row) }
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ fun SliderBar(
|
|||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
interval: Int = 1,
|
||||
color: Color = MaterialTheme.colorScheme.border,
|
||||
colors: SliderColors = SliderColors.default(),
|
||||
) {
|
||||
val isFocused by interactionSource.collectIsFocusedAsState()
|
||||
val animatedIndicatorHeight by animateDpAsState(
|
||||
|
|
@ -49,9 +49,6 @@ fun SliderBar(
|
|||
var currentValue by remember(value) { mutableLongStateOf(value) }
|
||||
val percent = (currentValue - min).toFloat() / (max - min)
|
||||
|
||||
val activeColor = SliderActiveColor(isFocused)
|
||||
val inactiveColor = SliderInactiveColor(isFocused)
|
||||
|
||||
val handleSeekEventModifier =
|
||||
Modifier.handleDPadKeyEvents(
|
||||
triggerOnAction = KeyEvent.ACTION_DOWN,
|
||||
|
|
@ -91,14 +88,14 @@ fun SliderBar(
|
|||
onDraw = {
|
||||
val yOffset = size.height.div(2)
|
||||
drawLine(
|
||||
color = inactiveColor,
|
||||
color = if (isFocused) colors.inactiveFocused else colors.inactiveUnfocused,
|
||||
start = Offset(x = 0f, y = yOffset),
|
||||
end = Offset(x = size.width, y = yOffset),
|
||||
strokeWidth = size.height,
|
||||
cap = StrokeCap.Round,
|
||||
)
|
||||
drawLine(
|
||||
color = activeColor,
|
||||
color = if (isFocused) colors.activeFocused else colors.activeUnfocused,
|
||||
start = Offset(x = 0f, y = yOffset),
|
||||
end =
|
||||
Offset(
|
||||
|
|
@ -119,6 +116,24 @@ fun SliderBar(
|
|||
}
|
||||
}
|
||||
|
||||
data class SliderColors(
|
||||
val activeFocused: Color,
|
||||
val activeUnfocused: Color,
|
||||
val inactiveFocused: Color,
|
||||
val inactiveUnfocused: Color,
|
||||
) {
|
||||
companion object {
|
||||
@Composable
|
||||
fun default() =
|
||||
SliderColors(
|
||||
activeFocused = SliderActiveColor(true),
|
||||
activeUnfocused = SliderActiveColor(false),
|
||||
inactiveFocused = SliderInactiveColor(true),
|
||||
inactiveUnfocused = SliderInactiveColor(false),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SliderActiveColor(focused: Boolean): Color {
|
||||
val theme = LocalTheme.current
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import androidx.annotation.StringRes
|
|||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.gestures.BringIntoViewSpec
|
||||
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
|
|
@ -24,6 +26,7 @@ import androidx.compose.foundation.lazy.layout.LazyLayoutCacheWindow
|
|||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
|
|
@ -112,6 +115,7 @@ fun <T : CardGridItem> CardGrid(
|
|||
},
|
||||
columns: Int = 6,
|
||||
spacing: Dp = 16.dp,
|
||||
bringIntoViewSpec: BringIntoViewSpec = LocalBringIntoViewSpec.current,
|
||||
) {
|
||||
val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0))
|
||||
|
||||
|
|
@ -269,100 +273,102 @@ fun <T : CardGridItem> CardGrid(
|
|||
Box(
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(columns),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing),
|
||||
state = gridState,
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.focusGroup()
|
||||
.focusRestorer(firstFocus)
|
||||
.focusProperties {
|
||||
onExit = {
|
||||
// Leaving the grid, so "forget" the position
|
||||
CompositionLocalProvider(LocalBringIntoViewSpec provides bringIntoViewSpec) {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(columns),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing),
|
||||
state = gridState,
|
||||
contentPadding = PaddingValues(vertical = 16.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.focusGroup()
|
||||
.focusRestorer(firstFocus)
|
||||
.focusProperties {
|
||||
onExit = {
|
||||
// Leaving the grid, so "forget" the position
|
||||
// focusedIndex = -1
|
||||
}
|
||||
onEnter = {
|
||||
if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) {
|
||||
focusedIndex = startPosition
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
items(pager.size) { index ->
|
||||
val mod =
|
||||
if ((index == focusedIndex) or (focusedIndex < 0 && index == 0)) {
|
||||
if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index")
|
||||
Modifier
|
||||
.focusRequester(firstFocus)
|
||||
.focusRequester(gridFocusRequester)
|
||||
.focusRequester(alphabetFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
val item = pager[index]
|
||||
cardContent(
|
||||
item,
|
||||
{
|
||||
if (item != null) {
|
||||
focusedIndex = index
|
||||
onClickItem.invoke(index, item)
|
||||
}
|
||||
},
|
||||
{ if (item != null) onLongClickItem.invoke(index, item) },
|
||||
mod
|
||||
.ifElse(index == 0, Modifier.focusRequester(zeroFocus))
|
||||
.onFocusChanged { focusState ->
|
||||
if (DEBUG) {
|
||||
Timber.v(
|
||||
"$index isFocused=${focusState.isFocused}",
|
||||
)
|
||||
onEnter = {
|
||||
if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) {
|
||||
focusedIndex = startPosition
|
||||
}
|
||||
}
|
||||
if (focusState.isFocused) {
|
||||
// Focused, so set that up
|
||||
focusOn(index)
|
||||
positionCallback?.invoke(columns, index)
|
||||
} else if (focusedIndex == index) {
|
||||
},
|
||||
) {
|
||||
items(pager.size) { index ->
|
||||
val mod =
|
||||
if ((index == focusedIndex) or (focusedIndex < 0 && index == 0)) {
|
||||
if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index")
|
||||
Modifier
|
||||
.focusRequester(firstFocus)
|
||||
.focusRequester(gridFocusRequester)
|
||||
.focusRequester(alphabetFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
val item = pager[index]
|
||||
cardContent(
|
||||
item,
|
||||
{
|
||||
if (item != null) {
|
||||
focusedIndex = index
|
||||
onClickItem.invoke(index, item)
|
||||
}
|
||||
},
|
||||
{ if (item != null) onLongClickItem.invoke(index, item) },
|
||||
mod
|
||||
.ifElse(index == 0, Modifier.focusRequester(zeroFocus))
|
||||
.onFocusChanged { focusState ->
|
||||
if (DEBUG) {
|
||||
Timber.v(
|
||||
"$index isFocused=${focusState.isFocused}",
|
||||
)
|
||||
}
|
||||
if (focusState.isFocused) {
|
||||
// Focused, so set that up
|
||||
focusOn(index)
|
||||
positionCallback?.invoke(columns, index)
|
||||
} else if (focusedIndex == index) {
|
||||
// savedFocusedIndex = index
|
||||
// // Was focused on this, so mark unfocused
|
||||
// focusedIndex = -1
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pager.isEmpty()) {
|
||||
if (pager.isEmpty()) {
|
||||
// focusedIndex = -1
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Text(
|
||||
text = stringResource(R.string.no_results),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Text(
|
||||
text = stringResource(R.string.no_results),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showFooter) {
|
||||
// Footer
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(AppColors.TransparentBlack50),
|
||||
) {
|
||||
val index = (focusedIndex + 1).takeIf { it > 0 } ?: "?"
|
||||
if (showFooter) {
|
||||
// Footer
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(AppColors.TransparentBlack50),
|
||||
) {
|
||||
val index = (focusedIndex + 1).takeIf { it > 0 } ?: "?"
|
||||
// if (focusedIndex >= 0) {
|
||||
// focusedIndex + 1
|
||||
// } else {
|
||||
// max(savedFocusedIndex, focusedIndexOnExit) + 1
|
||||
// }
|
||||
Text(
|
||||
modifier = Modifier.padding(4.dp),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
text = "$index / ${pager.size}",
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.padding(4.dp),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
text = "$index / ${pager.size}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -394,7 +400,7 @@ fun <T : CardGridItem> CardGrid(
|
|||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.CenterVertically)
|
||||
.padding(end = 16.dp),
|
||||
.padding(start = 16.dp),
|
||||
// Add end padding to push away from edge
|
||||
letterClicked = { letter ->
|
||||
scope.launch(ExceptionHandler()) {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
package com.github.damontecres.wholphin.ui.detail
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
|
|
@ -40,9 +38,7 @@ fun CollectionFolderBoxSet(
|
|||
recursive = recursive,
|
||||
sortOptions = BoxSetSortOptions,
|
||||
initialSortAndDirection = SortAndDirection(ItemSortBy.DEFAULT, SortOrder.ASCENDING),
|
||||
modifier =
|
||||
modifier
|
||||
.padding(start = 16.dp),
|
||||
modifier = modifier,
|
||||
positionCallback = { columns, position ->
|
||||
showHeader = position < columns
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
package com.github.damontecres.wholphin.ui.detail
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions
|
||||
import com.github.damontecres.wholphin.data.filter.ItemFilterBy
|
||||
|
|
@ -45,15 +43,15 @@ fun CollectionFolderGeneric(
|
|||
}
|
||||
CollectionFolderGrid(
|
||||
preferences = preferences,
|
||||
onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) },
|
||||
onClickItem = { index, item ->
|
||||
preferencesViewModel.navigationManager.navigateTo(item.destination(index))
|
||||
},
|
||||
itemId = itemId,
|
||||
initialFilter = filter,
|
||||
showTitle = showHeader,
|
||||
recursive = recursive,
|
||||
sortOptions = sortOptions,
|
||||
modifier =
|
||||
modifier
|
||||
.padding(start = 16.dp),
|
||||
modifier = modifier,
|
||||
positionCallback = { columns, position ->
|
||||
showHeader = position < columns
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
package com.github.damontecres.wholphin.ui.detail
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
|
|
@ -127,14 +125,14 @@ fun CollectionFolderLiveTv(
|
|||
) {
|
||||
AnimatedVisibility(
|
||||
showHeader,
|
||||
enter = slideInVertically() + fadeIn(),
|
||||
exit = slideOutVertically() + fadeOut(),
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
) {
|
||||
TabRow(
|
||||
selectedTabIndex = selectedTabIndex,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 32.dp, top = 16.dp, bottom = 16.dp)
|
||||
.padding(vertical = 16.dp)
|
||||
.focusRequester(firstTabFocusRequester),
|
||||
tabs = tabs.map { it.title },
|
||||
onClick = { selectedTabIndex = it },
|
||||
|
|
@ -176,9 +174,7 @@ fun CollectionFolderLiveTv(
|
|||
showTitle = false,
|
||||
recursive = false,
|
||||
sortOptions = VideoSortOptions,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 16.dp),
|
||||
modifier = Modifier,
|
||||
positionCallback = { columns, position ->
|
||||
showHeader = position < columns
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
package com.github.damontecres.wholphin.ui.detail
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
|
|
@ -76,14 +74,14 @@ fun CollectionFolderMovie(
|
|||
) {
|
||||
AnimatedVisibility(
|
||||
showHeader,
|
||||
enter = slideInVertically() + fadeIn(),
|
||||
exit = slideOutVertically() + fadeOut(),
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
) {
|
||||
TabRow(
|
||||
selectedTabIndex = selectedTabIndex,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 32.dp, top = 16.dp, bottom = 16.dp)
|
||||
.padding(vertical = 16.dp)
|
||||
.focusRequester(firstTabFocusRequester),
|
||||
tabs = tabs,
|
||||
onClick = { selectedTabIndex = it },
|
||||
|
|
@ -101,7 +99,6 @@ fun CollectionFolderMovie(
|
|||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 16.dp)
|
||||
.fillMaxSize()
|
||||
.focusRequester(focusRequester),
|
||||
)
|
||||
|
|
@ -129,7 +126,6 @@ fun CollectionFolderMovie(
|
|||
defaultViewOptions = ViewOptionsPoster,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 16.dp)
|
||||
.fillMaxSize()
|
||||
.focusRequester(focusRequester),
|
||||
positionCallback = { columns, position ->
|
||||
|
|
@ -162,7 +158,6 @@ fun CollectionFolderMovie(
|
|||
defaultViewOptions = ViewOptionsPoster,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 16.dp)
|
||||
.fillMaxSize()
|
||||
.focusRequester(focusRequester),
|
||||
positionCallback = { columns, position ->
|
||||
|
|
@ -180,7 +175,6 @@ fun CollectionFolderMovie(
|
|||
includeItemTypes = listOf(BaseItemKind.MOVIE),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 16.dp)
|
||||
.fillMaxSize()
|
||||
.focusRequester(focusRequester),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
package com.github.damontecres.wholphin.ui.detail
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions
|
||||
import com.github.damontecres.wholphin.data.filter.ItemFilterBy
|
||||
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
||||
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
|
||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderViewModel
|
||||
import com.github.damontecres.wholphin.ui.components.ViewOptionsWide
|
||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||
import com.github.damontecres.wholphin.ui.data.VideoSortOptions
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import java.util.UUID
|
||||
|
||||
@Composable
|
||||
fun CollectionFolderPhotoAlbum(
|
||||
preferences: UserPreferences,
|
||||
itemId: UUID,
|
||||
recursive: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
filter: CollectionFolderFilter = CollectionFolderFilter(),
|
||||
filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions,
|
||||
sortOptions: List<ItemSortBy> = VideoSortOptions,
|
||||
// Note: making the VM here and passing down is bad practice, but we need the grid state when clicking on items
|
||||
// TODO refactor this
|
||||
viewModel: CollectionFolderViewModel =
|
||||
hiltViewModel<CollectionFolderViewModel, CollectionFolderViewModel.Factory>(
|
||||
key = itemId.toServerString(),
|
||||
) {
|
||||
it.create(
|
||||
itemId = itemId.toServerString(),
|
||||
initialSortAndDirection = null,
|
||||
recursive = recursive,
|
||||
collectionFilter = filter,
|
||||
useSeriesForPrimary = false,
|
||||
defaultViewOptions = ViewOptionsWide,
|
||||
)
|
||||
},
|
||||
) {
|
||||
var showHeader by remember { mutableStateOf(true) }
|
||||
CollectionFolderGrid(
|
||||
preferences = preferences,
|
||||
onClickItem = { index, item ->
|
||||
val destination =
|
||||
if (item.type == BaseItemKind.PHOTO) {
|
||||
Destination.Slideshow(
|
||||
parentId = itemId,
|
||||
index = index,
|
||||
filter = CollectionFolderFilter(filter = viewModel.filter.value ?: GetItemsFilter()),
|
||||
sortAndDirection = viewModel.sortAndDirection.value ?: SortAndDirection.DEFAULT,
|
||||
recursive = true,
|
||||
startSlideshow = false,
|
||||
)
|
||||
} else {
|
||||
item.destination(index)
|
||||
}
|
||||
viewModel.navigateTo(destination)
|
||||
},
|
||||
itemId = itemId.toServerString(),
|
||||
initialFilter = filter,
|
||||
showTitle = showHeader,
|
||||
recursive = recursive,
|
||||
sortOptions = sortOptions,
|
||||
modifier = modifier,
|
||||
positionCallback = { columns, position ->
|
||||
showHeader = position < columns
|
||||
},
|
||||
defaultViewOptions = ViewOptionsWide,
|
||||
playEnabled = true,
|
||||
filterOptions = filterOptions,
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,13 +1,11 @@
|
|||
package com.github.damontecres.wholphin.ui.detail
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
|
|
@ -35,9 +33,7 @@ fun CollectionFolderPlaylist(
|
|||
showTitle = showHeader,
|
||||
recursive = recursive,
|
||||
sortOptions = PlaylistSortOptions,
|
||||
modifier =
|
||||
modifier
|
||||
.padding(start = 16.dp),
|
||||
modifier = modifier,
|
||||
positionCallback = { columns, position ->
|
||||
showHeader = position < columns
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
package com.github.damontecres.wholphin.ui.detail
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
|
|
@ -35,9 +33,7 @@ fun CollectionFolderRecordings(
|
|||
showTitle = showHeader,
|
||||
recursive = recursive,
|
||||
sortOptions = MovieSortOptions,
|
||||
modifier =
|
||||
modifier
|
||||
.padding(start = 16.dp),
|
||||
modifier = modifier,
|
||||
positionCallback = { columns, position ->
|
||||
showHeader = position < columns
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
package com.github.damontecres.wholphin.ui.detail
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
|
|
@ -80,14 +78,14 @@ fun CollectionFolderTv(
|
|||
) {
|
||||
AnimatedVisibility(
|
||||
showHeader,
|
||||
enter = slideInVertically() + fadeIn(),
|
||||
exit = slideOutVertically() + fadeOut(),
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
) {
|
||||
TabRow(
|
||||
selectedTabIndex = selectedTabIndex,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 32.dp, top = 16.dp, bottom = 16.dp)
|
||||
.padding(vertical = 16.dp)
|
||||
.focusRequester(firstTabFocusRequester),
|
||||
tabs = tabs,
|
||||
onClick = { selectedTabIndex = it },
|
||||
|
|
@ -105,7 +103,6 @@ fun CollectionFolderTv(
|
|||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 16.dp)
|
||||
.fillMaxSize()
|
||||
.focusRequester(focusRequester),
|
||||
)
|
||||
|
|
@ -129,7 +126,6 @@ fun CollectionFolderTv(
|
|||
defaultViewOptions = ViewOptionsPoster,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 16.dp)
|
||||
.fillMaxSize()
|
||||
.focusRequester(focusRequester),
|
||||
positionCallback = { columns, position ->
|
||||
|
|
@ -150,7 +146,6 @@ fun CollectionFolderTv(
|
|||
includeItemTypes = listOf(BaseItemKind.SERIES),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 16.dp)
|
||||
.fillMaxSize()
|
||||
.focusRequester(focusRequester),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
package com.github.damontecres.wholphin.ui.detail
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.view.Display
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
|
|
@ -32,11 +34,11 @@ import androidx.lifecycle.viewModelScope
|
|||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.BuildConfig
|
||||
import com.github.damontecres.wholphin.MainActivity
|
||||
import com.github.damontecres.wholphin.data.ItemPlaybackDao
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.RefreshRateService
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
|
|
@ -49,6 +51,7 @@ import org.jellyfin.sdk.api.client.ApiClient
|
|||
import org.jellyfin.sdk.api.client.extensions.clientLogApi
|
||||
import org.jellyfin.sdk.model.ClientInfo
|
||||
import org.jellyfin.sdk.model.DeviceInfo
|
||||
import timber.log.Timber
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import javax.inject.Inject
|
||||
|
|
@ -59,13 +62,19 @@ class DebugViewModel
|
|||
constructor(
|
||||
val serverRepository: ServerRepository,
|
||||
val itemPlaybackDao: ItemPlaybackDao,
|
||||
val refreshRateService: RefreshRateService,
|
||||
val clientInfo: ClientInfo,
|
||||
val deviceInfo: DeviceInfo,
|
||||
) : ViewModel() {
|
||||
val itemPlaybacks = MutableLiveData<List<ItemPlayback>>(listOf())
|
||||
val logcat = MutableLiveData<List<LogcatLine>>(listOf())
|
||||
|
||||
val supportedModes by lazy {
|
||||
val displayManager =
|
||||
MainActivity.instance.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
|
||||
val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
|
||||
display.supportedModes.orEmpty()
|
||||
}
|
||||
|
||||
init {
|
||||
viewModelScope.launchIO {
|
||||
serverRepository.currentUser.value?.rowId?.let {
|
||||
|
|
@ -137,9 +146,13 @@ class DebugViewModel
|
|||
Send App Logs
|
||||
clientInfo=$clientInfo
|
||||
deviceInfo=$deviceInfo
|
||||
manufacturer=${Build.MANUFACTURER}
|
||||
model=${Build.MODEL}
|
||||
apiLevel=${Build.VERSION.SDK_INT}
|
||||
|
||||
""".trimIndent() + logcat
|
||||
val response by api.clientLogApi.logFile(body)
|
||||
""".trimIndent()
|
||||
Timber.w(body)
|
||||
val response by api.clientLogApi.logFile(body + logcat)
|
||||
showToast(context, "Sent! Filename=${response.fileName}")
|
||||
}
|
||||
}
|
||||
|
|
@ -253,8 +266,9 @@ fun DebugPage(
|
|||
"DeviceInfo: ${viewModel.deviceInfo}",
|
||||
"Manufacturer: ${Build.MANUFACTURER}",
|
||||
"Model: ${Build.MODEL}",
|
||||
"API Level: ${Build.VERSION.SDK_INT}",
|
||||
"Display Modes:",
|
||||
*viewModel.refreshRateService.supportedDisplayModes,
|
||||
*viewModel.supportedModes,
|
||||
).forEach {
|
||||
Text(
|
||||
text = it.toString(),
|
||||
|
|
|
|||
|
|
@ -25,9 +25,11 @@ import kotlin.time.Duration.Companion.seconds
|
|||
|
||||
data class MoreDialogActions(
|
||||
val navigateTo: (Destination) -> Unit,
|
||||
var onClickWatch: (UUID, Boolean) -> Unit,
|
||||
var onClickFavorite: (UUID, Boolean) -> Unit,
|
||||
var onClickAddPlaylist: (UUID) -> Unit,
|
||||
val onClickWatch: (UUID, Boolean) -> Unit,
|
||||
val onClickFavorite: (UUID, Boolean) -> Unit,
|
||||
val onClickAddPlaylist: (UUID) -> Unit,
|
||||
val onSendMediaInfo: (UUID) -> Unit,
|
||||
val onClickDelete: (BaseItem) -> Unit,
|
||||
)
|
||||
|
||||
enum class ClearChosenStreams {
|
||||
|
|
@ -61,6 +63,7 @@ fun buildMoreDialogItems(
|
|||
watched: Boolean,
|
||||
favorite: Boolean,
|
||||
canClearChosenStreams: Boolean,
|
||||
canDelete: Boolean,
|
||||
actions: MoreDialogActions,
|
||||
onChooseVersion: () -> Unit,
|
||||
onChooseTracks: (MediaStreamType) -> Unit,
|
||||
|
|
@ -138,6 +141,17 @@ fun buildMoreDialogItems(
|
|||
actions.onClickAddPlaylist.invoke(item.id)
|
||||
},
|
||||
)
|
||||
if (canDelete) {
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.delete),
|
||||
Icons.Default.Delete,
|
||||
iconColor = Color.Red.copy(alpha = .8f),
|
||||
) {
|
||||
actions.onClickDelete.invoke(item)
|
||||
},
|
||||
)
|
||||
}
|
||||
add(
|
||||
DialogItem(
|
||||
text = if (watched) R.string.mark_unwatched else R.string.mark_watched,
|
||||
|
|
@ -205,6 +219,14 @@ fun buildMoreDialogItems(
|
|||
)
|
||||
},
|
||||
)
|
||||
add(
|
||||
DialogItem(
|
||||
text = R.string.send_media_info_log_to_server,
|
||||
iconStringRes = R.string.fa_file_video,
|
||||
) {
|
||||
actions.onSendMediaInfo.invoke(item.id)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun buildMoreDialogItemsForHome(
|
||||
|
|
@ -214,6 +236,7 @@ fun buildMoreDialogItemsForHome(
|
|||
playbackPosition: Duration,
|
||||
watched: Boolean,
|
||||
favorite: Boolean,
|
||||
canDelete: Boolean,
|
||||
actions: MoreDialogActions,
|
||||
): List<DialogItem> =
|
||||
buildList {
|
||||
|
|
@ -281,6 +304,17 @@ fun buildMoreDialogItemsForHome(
|
|||
actions.onClickAddPlaylist.invoke(itemId)
|
||||
},
|
||||
)
|
||||
if (canDelete) {
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.delete),
|
||||
Icons.Default.Delete,
|
||||
iconColor = Color.Red.copy(alpha = .8f),
|
||||
) {
|
||||
actions.onClickDelete.invoke(item)
|
||||
},
|
||||
)
|
||||
}
|
||||
add(
|
||||
DialogItem(
|
||||
text = if (watched) R.string.mark_unwatched else R.string.mark_watched,
|
||||
|
|
@ -314,6 +348,14 @@ fun buildMoreDialogItemsForHome(
|
|||
},
|
||||
)
|
||||
}
|
||||
add(
|
||||
DialogItem(
|
||||
text = R.string.send_media_info_log_to_server,
|
||||
iconStringRes = R.string.fa_file_video,
|
||||
) {
|
||||
actions.onSendMediaInfo.invoke(itemId)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun buildMoreDialogItemsForPerson(
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
package com.github.damontecres.wholphin.ui.detail
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
|
|
@ -98,8 +96,8 @@ fun FavoritesPage(
|
|||
) {
|
||||
AnimatedVisibility(
|
||||
showHeader,
|
||||
enter = slideInVertically() + fadeIn(),
|
||||
exit = slideOutVertically() + fadeOut(),
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
) {
|
||||
TabRow(
|
||||
selectedTabIndex = selectedTabIndex,
|
||||
|
|
|
|||
|
|
@ -298,9 +298,7 @@ fun PersonPageContent(
|
|||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
userScrollEnabled = !focusedOnHeader,
|
||||
modifier =
|
||||
modifier
|
||||
.padding(start = 16.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
item {
|
||||
PersonHeader(
|
||||
|
|
|
|||
|
|
@ -373,7 +373,6 @@ fun PlaylistDetailsContent(
|
|||
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
PlaylistDetailsHeader(
|
||||
|
|
@ -386,7 +385,7 @@ fun PlaylistDetailsContent(
|
|||
getPossibleFilterValues = getPossibleFilterValues,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 16.dp, top = 80.dp)
|
||||
.padding(top = 80.dp)
|
||||
.fillMaxWidth(.25f),
|
||||
)
|
||||
when (loadingState) {
|
||||
|
|
|
|||
|
|
@ -60,7 +60,6 @@ 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
|
||||
|
|
@ -102,23 +101,15 @@ fun DiscoverMovieDetails(
|
|||
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)
|
||||
ErrorMessage(state, modifier)
|
||||
}
|
||||
|
||||
LoadingState.Loading,
|
||||
LoadingState.Pending,
|
||||
-> {
|
||||
LoadingPage()
|
||||
LoadingPage(modifier)
|
||||
}
|
||||
|
||||
LoadingState.Success -> {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue