Compare commits

..

No commits in common. "365f8f8adacf4dcaf7d05317917830ed3a439668" and "81e5f31401e7e26cb3677248eedafc921bf14b05" have entirely different histories.

372 changed files with 10382 additions and 29013 deletions

View file

@ -1,5 +1,6 @@
name: "Bug Report"
description: Report a bug
title: "<title>"
labels: [
"bug"
]
@ -22,16 +23,6 @@ body:
3. See error
validations:
required: false
- type: dropdown
id: experimental
attributes:
label: Experimental settings
description: Do you have experiment settings turn on?
options:
- "Yes"
- "No"
validations:
required: true
- type: input
id: app-version
attributes:

View file

@ -1,5 +1,6 @@
name: "Playback Problem"
description: Report an issue with playback
title: "<title>"
labels: [
"bug", "playback"
]
@ -11,14 +12,12 @@ body:
description: Please enter a description of the playback issue
validations:
required: true
- type: dropdown
id: experimental
- type: textarea
id: mediainfo
attributes:
label: Experimental settings
description: Do you have experiment settings turn on?
options:
- "Yes"
- "No"
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
@ -33,14 +32,6 @@ body:
- External App (please specify)
validations:
required: true
- type: textarea
id: mediainfo
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: input
id: app-version
attributes:

View file

@ -6,11 +6,11 @@ runs:
steps:
# Setup the SDKs
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
uses: actions/setup-python@v6
with:
python-version: '3.14'
- name: Setup JDK
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6
uses: actions/setup-java@v5
with:
distribution: zulu
java-version: '21'
@ -21,9 +21,9 @@ runs:
echo "BUILD_TOOLS_VERSION=36.0.0" >> $GITHUB_ENV
echo "NDK_VERSION=29.0.14206865" >> $GITHUB_ENV
- name: Setup Android SDK
uses: android-actions/setup-android@be39fa834029ff78f1a44aa3bb0819b8fc2bd8fd # v4
uses: android-actions/setup-android@v4
with:
packages: "platform-tools build-tools;${{ env.BUILD_TOOLS_VERSION }} ndk;${{ env.NDK_VERSION }}"
packages: "tools platform-tools build-tools;${{ env.BUILD_TOOLS_VERSION }} ndk;${{ env.NDK_VERSION }}"
- name: Add NDK to path
shell: bash
run: |

View file

@ -16,7 +16,7 @@ concurrency:
env:
BRANCH_NAME: ${{ github.ref_name }}
GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx12g -XX:MaxMetaspaceSize=1024m'"
GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx8g -XX:MaxMetaspaceSize=512m'"
jobs:
build:
@ -25,7 +25,7 @@ jobs:
contents: write
steps:
- name: Checkout the code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
uses: actions/checkout@v6
with:
fetch-depth: 0 # Need the tags to build
- name: Setup
@ -54,8 +54,7 @@ jobs:
ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}"
ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}"
run: |
./gradlew clean assembleDefaultDebug
./gradlew assembleDefaultRelease
./gradlew clean assembleDefaultRelease assembleDefaultDebug --no-daemon
- name: Verify signatures
run: |
@ -77,7 +76,7 @@ jobs:
echo "SHA256 checksums:"
find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 sha256sum
- name: Upload mapping files
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: mapping
path: |

View file

@ -9,29 +9,29 @@ defaults:
env:
BUILD_DIRS_ARTIFACT: build-dirs
GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx12g -XX:MaxMetaspaceSize=1024m'"
GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx8g -XX:MaxMetaspaceSize=512m'"
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- name: Checkout the code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
uses: actions/checkout@v6
with:
fetch-depth: 0 # Need the tags to build
- name: Setup Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
uses: actions/setup-python@v6
with:
python-version: '3.14'
- name: Run pre-commit
uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1
uses: pre-commit/action@v3.0.1
build:
runs-on: ubuntu-latest
needs: pre-commit
steps:
- name: Checkout the code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
uses: actions/checkout@v6
with:
fetch-depth: 0 # Need the tags to build
- name: Setup

View file

@ -11,7 +11,7 @@ defaults:
env:
APK_SHORT_NAME: Wholphin.apk
GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx12g -XX:MaxMetaspaceSize=1024m'"
GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx8g -XX:MaxMetaspaceSize=512m'"
TAG_NAME: ${{ github.ref_name }}
jobs:
@ -21,7 +21,7 @@ jobs:
contents: write
steps:
- name: Checkout the code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
uses: actions/checkout@v6
with:
fetch-depth: 0 # Need the tags to build
- name: Setup
@ -36,8 +36,7 @@ jobs:
ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}"
ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}"
run: |
./gradlew clean bundleAppstoreRelease
./gradlew bundleFiretvRelease
./gradlew clean bundleAppstoreRelease bundleFiretvRelease
./gradlew assembleDefaultRelease
- name: Verify signatures
run: |
@ -59,14 +58,14 @@ jobs:
echo "SHA256 checksums:"
find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 sha256sum
- name: Upload AAB
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: AAB
path: |
app/build/outputs/bundle/**/*.aab
compression-level: 0
- name: Upload mapping files
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: mapping
path: |

View file

@ -42,8 +42,6 @@ If you want to include these in a local build, see the [instructions here](https
You can also build the extensions locally from https://github.com/damontecres/wholphin-extensions and include them in `app/libs`. The gradle build dependency resolution prefers these local files over fetching from the remote maven registry.
Finally, if no `wholphin-mpv` implementation is found, `wholphin-mpv-stub` will be used. This allows the app to compile, but any runtime usage of MPV will throw an exception.
## Code organization
Code is split into several packages:
@ -97,9 +95,3 @@ Some of the models, such as `GetItemsFilter` or `ViewOptions`, are stored as JSO
These settings are similar to Room ones and are usually stored per user. These can be accessed via `KeyValueService`. The values are `@Serializable` objects saved as JSON.
This should be used sparingly.
#### Experimental
Some settings are considered "experimental". These may cause instabilities or may need further testing. They may be changed or removed any time.
These are stored in the `ExperimentalPreferences` object in `AppPreferences`. Accessing them should always be gated with `ExperimentalPreferences.enabled`. There are a few helper functions for this purpose.

View file

@ -1,168 +0,0 @@
# Intents
> [!IMPORTANT]
> This feature is experimental and the exact behaviors and/or parameters may change at any time
Wholphin accepts [intents](https://developer.android.com/guide/components/intents-filters) to load or play specified media.
Parameters can be specified as "extras" (eg `--es query value`) or as query parameters to the data URI (eg `-d wholphin://search?query=value`).
UUID parameters can be specified with or without hyphens.
If using data URI query parameters on the command line, you need to escape ampersands: `itemId=...\&userId=...`
## Server/user switching
You can choose a server and user to switch to with the `serverId` and `userId` UUID parameters. If the profile is not protected, the app will switch to it.
If not specified, the app will use the current user if auto sign in is enabled.
## Search
Action: `android.intent.action.SEARCH`
Shorthand action: `search`
Parameters:
- `query` - Optional, prepopulate the search field
### Examples
Full format
```bash
adb shell am start \
-a android.intent.action.SEARCH \
-n 'com.github.damontecres.wholphin/.MainActivity' \
--es query "Avengers"
```
URI based
```bash
adb shell am start -d 'wholphin://search?query=Avengers'
```
Multi-word query:
```bash
# Whole shell command is wrapped in double quotes
adb shell "am start \
-a android.intent.action.SEARCH \
-n 'com.github.damontecres.wholphin/.MainActivity' \
--es query 'Avengers Endgame'"
adb shell am start -d 'wholphin://search?query=Avengers+Endgame'
adb shell am start -d 'wholphin://search?query=Avengers%20Endgame'
```
## View
This opens the app to the specified item such as an episode or movie
Action: `android.intent.action.VIEW`
Shorthand action: `view`
Parameters:
- `itemId` - Optional, the UUID of the media item, if not set, goes to home screen
### Examples
Full format
```bash
adb shell am start \
-a android.intent.action.VIEW \
-n 'com.github.damontecres.wholphin/.MainActivity' \
--es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d"
```
URI based
```bash
adb shell am start -d 'wholphin://view?itemId=5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d'
```
## Playback
Action: `com.github.damontecres.wholphin.PLAYBACK`
Shorthand action: `play`
Parameters:
- `itemId` - Required, the UUID of the media item
- `position` - Optional, the start position for playback in milliseconds
- `shuffle` - Optional, whether to shuffle the resulting items
### Examples
Full format
```bash
adb shell am start \
-a com.github.damontecres.wholphin.PLAYBACK \
-n 'com.github.damontecres.wholphin/.MainActivity' \
--es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d"
```
URI based
```bash
adb shell am start -d 'wholphin://play?itemId=5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d'
```
Start at 4m30s:
```bash
adb shell am start \
-a com.github.damontecres.wholphin.PLAYBACK \
-n 'com.github.damontecres.wholphin/.MainActivity' \
--es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" \
--el position 270000
adb shell am start -d 'wholphin://play?itemId=5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d\&position=270000'
```
Shuffle a series (`itemId` is the Series ID):
```bash
adb shell am start \
-a com.github.damontecres.wholphin.PLAYBACK \
-n 'com.github.damontecres.wholphin/.MainActivity' \
--es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" \
--ez shuffle true
adb shell am start -d 'wholphin://play?itemId=5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d\&shuffle=true'
```
## More examples
### Switch user
Full format
```bash
# Switch user, go to item
adb shell am start \
-a android.intent.action.VIEW \
-n 'com.github.damontecres.wholphin/.MainActivity' \
--es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" \
--es serverId "341880f9-ca88-4038-8718-f6b2407950b1" \
--es userId "5c8575e0-468d-44fb-8c70-00aa79160587"
# Switch user, go to home screen
adb shell am start \
-a android.intent.action.VIEW \
-n 'com.github.damontecres.wholphin/.MainActivity' \
--es serverId "341880f9-ca88-4038-8718-f6b2407950b1" \
--es userId "5c8575e0-468d-44fb-8c70-00aa79160587"
```
URI based
```bash
adb shell am start -d 'wholphin://view?itemId=66c80aaac9c17d3761782d2205520229\&serverId=341880f9-ca88-4038-8718-f6b2407950b1\&userId=5c8575e0-468d-44fb-8c70-00aa79160587'
```
### Debug build
Debug builds have a different package, so you must specify the component by its full name:
```bash
adb shell am start \
-a android.intent.action.VIEW \
-n 'com.github.damontecres.wholphin.debug/com.github.damontecres.wholphin.MainActivity' \
--es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d"
```

View file

@ -103,7 +103,7 @@ Requires Android 6+ (or Fire TV OS 6+) and Jellyfin server `10.10.x` or `10.11.x
The app is tested on a variety of Android TV/Fire TV OS devices, but if you encounter issues, please file an issue!
Seerr integration is generally tested with the latest stable version. Older versions may not work as expected. The Seerr server should configured with integration to your Jellyfin server for full compatibility.
Seerr integration is tested with `v3.2.0`. Older versions may not work.
## Contributions

View file

@ -20,21 +20,12 @@ plugins {
alias(libs.plugins.openapi.generator)
}
val isCI = providers.environmentVariable("CI").orElse("false").map { it.toBoolean() }
val shouldSign =
isCI.zip(
providers.environmentVariable("KEY_ALIAS").orElse("").map { it.isNotBlank() },
) { isCI, hasKey ->
isCI && hasKey
}
val ffmpegModuleExists =
providers.provider { project.file("libs/lib-decoder-ffmpeg-release.aar").exists() }
val av1ModuleExists =
providers.provider { project.file("libs/lib-decoder-av1-release.aar").exists() }
val mpvModuleExists =
providers.provider { project.file("libs/wholphin-mpv-release.aar").exists() }
val extensionsRepoActive =
providers.provider { project.hasProperty("WholphinExtensionsUsername") }
val isCI = if (System.getenv("CI") != null) System.getenv("CI").toBoolean() else false
val shouldSign = isCI && System.getenv("KEY_ALIAS") != null
val ffmpegModuleExists = project.file("libs/lib-decoder-ffmpeg-release.aar").exists()
val av1ModuleExists = project.file("libs/lib-decoder-av1-release.aar").exists()
val mpvModuleExists = project.file("libs/wholphin-mpv-release.aar").exists()
val extensionsRepoActive = project.hasProperty("WholphinExtensionsUsername")
// See https://issuetracker.google.com/issues/402800800
val isBuildingBundle =
@ -59,22 +50,17 @@ kotlin {
languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3
jvmTarget = JvmTarget.JVM_11
javaParameters = true
// Workaround for https://github.com/google/ksp/issues/2964
moduleName = "${project.group}_${project.name}"
}
}
private fun Provider<String>.getInt() = get().toInt()
configure<ApplicationExtension> {
namespace = "com.github.damontecres.wholphin"
compileSdk = libs.versions.compileSdk.getInt()
compileSdk = 36
defaultConfig {
applicationId = "com.github.damontecres.wholphin"
minSdk = libs.versions.minSdk.getInt()
targetSdk = libs.versions.targetSdk.getInt()
minSdk = 23
targetSdk = 36
versionCode = gitTags.trim().lines().size
versionName = gitDescribe.trim().removePrefix("v").ifBlank { "0.0.0" }
testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner"
@ -83,7 +69,7 @@ configure<ApplicationExtension> {
}
signingConfigs {
if (shouldSign.get()) {
if (shouldSign) {
create("ci") {
file("ci.keystore").writeBytes(
Base64.getDecoder().decode(System.getenv("SIGNING_KEY")),
@ -109,7 +95,7 @@ configure<ApplicationExtension> {
"proguard-rules.pro",
)
isDebuggable = false
if (shouldSign.get()) {
if (shouldSign) {
signingConfig = signingConfigs.getByName("ci")
} else {
val localPropertiesFile = project.rootProject.file("local.properties")
@ -180,7 +166,7 @@ configure<ApplicationExtension> {
isEnable = !isBuildingBundle.get()
reset()
include("armeabi-v7a", "arm64-v8a", "x86_64")
include("armeabi-v7a", "arm64-v8a")
isUniversalApk = true
}
}
@ -337,6 +323,7 @@ dependencies {
implementation(libs.androidx.room.common.jvm)
implementation(libs.androidx.room.ktx)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.preference.ktx)
implementation(libs.androidx.room.testing)
@ -364,39 +351,40 @@ dependencies {
implementation(libs.kache)
implementation(libs.kache.file)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
coreLibraryDesugaring(libs.desugar.jdk.libs)
if (ffmpegModuleExists.get()) {
if (ffmpegModuleExists) {
logger.info("Using local ffmpeg decoder")
implementation(files("libs/lib-decoder-ffmpeg-release.aar"))
} else if (extensionsRepoActive.get()) {
} else if (extensionsRepoActive) {
logger.info("Using prebuilt ffmpeg decoder")
implementation(libs.wholphin.extensions.ffmpeg)
} else {
logger.warn("Media3 ffmpeg decoder was NOT found")
}
if (av1ModuleExists.get()) {
if (av1ModuleExists) {
logger.info("Using local av1 decoder")
implementation(files("libs/lib-decoder-av1-release.aar"))
} else if (extensionsRepoActive.get()) {
} else if (extensionsRepoActive) {
logger.info("Using prebuilt av1 decoder")
implementation(libs.wholphin.extensions.av1)
} else {
logger.warn("Media3 av1 decoder was NOT found")
}
if (mpvModuleExists.get()) {
if (mpvModuleExists) {
logger.info("Using local libMPV build")
implementation(files("libs/wholphin-mpv-release.aar"))
} else if (extensionsRepoActive.get()) {
} else if (extensionsRepoActive) {
logger.info("Using prebuilt libMPV")
implementation(libs.wholphin.extensions.mpv)
} else {
logger.warn("libMPV was NOT found, using stub library")
implementation(project(":wholphin-mpv-stub"))
logger.warn("libMPV was NOT found")
}
debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.androidx.compose.ui.tooling)
testImplementation(libs.mockk.android)
testImplementation(libs.mockk.agent)
testImplementation(libs.kotlinx.coroutines.test)
@ -404,8 +392,7 @@ dependencies {
testImplementation(libs.robolectric)
testImplementation(libs.hilt.android.testing)
testImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.mockk.android)
androidTestImplementation(libs.hilt.android.testing)
androidTestImplementation(libs.androidx.compose.ui.test.manifest)
androidTestImplementation(libs.androidx.ui.test.manifest)
}

View file

@ -1,6 +1,6 @@
-keepattributes SourceFile,LineNumberTable
-keep class com.github.damontecres.wholphin.mpv.MPVLib { *; }
-keep class com.github.damontecres.wholphin.util.mpv.* { *; }
-keep class * extends com.google.protobuf.GeneratedMessageLite { *; }
-keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite {
@ -11,14 +11,3 @@
# TODO investigate using smaller scope
-keep class com.google.common.cache.** { *; }
# Media3SubtitleOverride uses reflection to access/modify these fields
-keep class androidx.media3.ui.SubtitleView {
private androidx.media3.ui.SubtitleView$Output output;
}
-keep class androidx.media3.ui.CanvasSubtitleOutput {
private final java.util.List painters;
}
-keep class androidx.media3.ui.SubtitlePainter {
private final float outlineWidth;
}

View file

@ -1,720 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 34,
"identityHash": "278e485064fc9f184a2181599f0f8efb",
"entities": [
{
"tableName": "servers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, `lastUsed` 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"
},
{
"fieldPath": "lastUsed",
"columnName": "lastUsed",
"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, `requireLogin` INTEGER NOT NULL DEFAULT false, `lastUsed` TEXT, `uiLanguage` 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"
},
{
"fieldPath": "requireLogin",
"columnName": "requireLogin",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "lastUsed",
"columnName": "lastUsed",
"affinity": "TEXT"
},
{
"fieldPath": "uiLanguage",
"columnName": "uiLanguage",
"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"
]
}
]
},
{
"tableName": "RememberedTab",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `index` INTEGER 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": "index",
"columnName": "index",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"itemId"
]
},
"indices": [
{
"name": "index_RememberedTab_userId_itemId",
"unique": true,
"columnNames": [
"userId",
"itemId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_RememberedTab_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '278e485064fc9f184a2181599f0f8efb')"
]
}
}

View file

@ -1,741 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 35,
"identityHash": "ff9ade58d4ac39e36335ac0df1405a5f",
"entities": [
{
"tableName": "servers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, `lastUsed` 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"
},
{
"fieldPath": "lastUsed",
"columnName": "lastUsed",
"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, `requireLogin` INTEGER NOT NULL DEFAULT false, `lastUsed` TEXT, `uiLanguage` TEXT, `preferredAudioLanguage` TEXT NOT NULL DEFAULT '', `preferredSubtitleLanguage` TEXT NOT NULL DEFAULT '', `subtitleMode` TEXT NOT NULL DEFAULT 'USE_USER_PROFILE', 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"
},
{
"fieldPath": "requireLogin",
"columnName": "requireLogin",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "lastUsed",
"columnName": "lastUsed",
"affinity": "TEXT"
},
{
"fieldPath": "uiLanguage",
"columnName": "uiLanguage",
"affinity": "TEXT"
},
{
"fieldPath": "appPreferences.preferredAudioLanguage",
"columnName": "preferredAudioLanguage",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "appPreferences.preferredSubtitleLanguage",
"columnName": "preferredSubtitleLanguage",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "appPreferences.subtitleMode",
"columnName": "subtitleMode",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'USE_USER_PROFILE'"
}
],
"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"
]
}
]
},
{
"tableName": "RememberedTab",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `index` INTEGER 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": "index",
"columnName": "index",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"itemId"
]
},
"indices": [
{
"name": "index_RememberedTab_userId_itemId",
"unique": true,
"columnNames": [
"userId",
"itemId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_RememberedTab_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ff9ade58d4ac39e36335ac0df1405a5f')"
]
}
}

View file

@ -4,7 +4,6 @@
android:installLocation="auto">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<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" />
@ -52,21 +51,13 @@
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize|density">
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>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<action android:name="android.intent.action.SEARCH" />
<action android:name="com.github.damontecres.wholphin.PLAYBACK" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="wholphin" />
</intent-filter>
</activity>
<provider

View file

@ -6,7 +6,6 @@ import android.content.res.Configuration
import android.os.Bundle
import android.view.KeyEvent
import android.view.WindowManager
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
@ -28,8 +27,6 @@ import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewModelScope
import androidx.navigation3.runtime.NavBackStack
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.AppPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend
@ -38,8 +35,6 @@ import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.DatePlayedInvalidationService
import com.github.damontecres.wholphin.services.DeviceProfileService
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.IntentResult
import com.github.damontecres.wholphin.services.IntentService
import com.github.damontecres.wholphin.services.LatestNextUpSchedulerService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver
@ -50,7 +45,6 @@ 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.UserPreferencesService
import com.github.damontecres.wholphin.services.UserSwitchListener
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService
@ -58,20 +52,19 @@ import com.github.damontecres.wholphin.ui.CoilConfig
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.collectLatestIn
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.nav.Destination
import com.github.damontecres.wholphin.ui.playback.PlayExternalViewModel
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.theme.colors.PurpleThemeColors
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
import com.github.damontecres.wholphin.util.DebugLogTree
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.requestSerializersModule
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
@ -79,15 +72,14 @@ import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@ -97,9 +89,6 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var userPreferencesDataStore: DataStore<AppPreferences>
@Inject
lateinit var userPreferencesService: UserPreferencesService
@AuthOkHttpClient
@Inject
lateinit var okHttpClient: OkHttpClient
@ -148,16 +137,11 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var screensaverService: ScreensaverService
@Inject
lateinit var intentService: IntentService
private var signInAuto = true
private var playerBackend: PlayerBackend? = null
private val json =
Json {
classDiscriminator = "_type"
serializersModule = requestSerializersModule
}
@OptIn(ExperimentalTvMaterial3Api::class)
@ -168,51 +152,37 @@ class MainActivity : AppCompatActivity() {
lifecycle.addObserver(playbackLifecycleObserver)
val backStackStr = savedInstanceState?.getString(KEY_BACK_STACK)
val restoredBackStack =
if (backStackStr != null) {
try {
json.decodeFromString<List<Destination>>(backStackStr)
} catch (ex: Exception) {
// Best-effort: a previously persisted back stack we can no longer decode
// must not crash startup; fall back to a fresh start destination.
Timber.w(ex, "Could not restore back stack; starting fresh")
null
}
} else {
null
}
if (restoredBackStack != null) {
Timber.d("Restoring back stack")
var backStack = restoredBackStack
var backStack = json.decodeFromString<List<Destination>>(backStackStr)
if (!playExternalViewModel.launched.value) {
val lastDest = backStack.lastOrNull()
if (lastDest.isPlayback) {
Timber.v("Restoring back stack without playback")
Timber.v("Restoring back stack with playback")
backStack = backStack.toMutableList().apply { removeAt(lastIndex) }
}
}
navigationManager.backStack = NavBackStack(*backStack.toTypedArray())
} else {
navigationManager.backStack = NavBackStack(Destination.Home())
val startDestination = intent?.let(::extractDestination) ?: Destination.Home()
navigationManager.backStack = NavBackStack(startDestination)
}
viewModel.serverRepository.currentUserFlow
.onEach { user ->
withContext(WholphinDispatchers.Main) {
viewModel.serverRepository.currentUserFlow.collectLatestIn(lifecycleScope) { user ->
if (user?.hasPin == true) {
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
window?.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE,
)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
}.catch { ex ->
Timber.e(ex, "Error with settings flag secure")
}.launchIn(lifecycleScope)
screensaverService.keepScreenOn
.onEach { keepScreenOn ->
Timber.v("keepScreenOn: %s", keepScreenOn)
withContext(WholphinDispatchers.Main) {
withContext(Dispatchers.Main) {
if (keepScreenOn) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
@ -223,21 +193,14 @@ class MainActivity : AppCompatActivity() {
Timber.e(ex, "Error with keepScreenOn")
}.launchIn(lifecycleScope)
userPreferencesDataStore.data.collectLatestIn(lifecycleScope) { prefs ->
signInAuto = prefs.signInAutomatically
playerBackend = prefs.playbackPreferences.playerBackend
}
viewModel.appStart(intent)
viewModel.appStart()
setContent {
MaterialTheme(colorScheme = PurpleThemeColors.darkScheme) {
Surface(Modifier.fillMaxSize()) {
val userPreferences by userPreferencesService.flow.collectAsState(null)
if (userPreferences == null) {
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.milliseconds)
delay(500)
Timber.i("Showing loading page")
showLoading = true
}
@ -251,9 +214,11 @@ class MainActivity : AppCompatActivity() {
LoadingPage()
}
}
} else {
userPreferences?.let { userPreferences ->
val appPreferences = userPreferences.appPreferences
}
appPreferences?.let { appPreferences ->
LaunchedEffect(appPreferences.signInAutomatically) {
signInAuto = appPreferences.signInAutomatically
}
CoilConfig(
prefs = appPreferences,
okHttpClient = okHttpClient,
@ -272,7 +237,7 @@ class MainActivity : AppCompatActivity() {
MainContent(
backStack = setupNavigationManager.backStack,
navigationManager = navigationManager,
userPreferences = userPreferences,
appPreferences = appPreferences,
backdropService = backdropService,
screensaverService = screensaverService,
modifier = Modifier.fillMaxSize(),
@ -283,12 +248,9 @@ class MainActivity : AppCompatActivity() {
}
}
}
}
}
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (screensaverService.state.value.run { show || showDim }) {
if (screensaverService.state.value.show) {
screensaverService.stop(false)
screensaverService.pulse()
return true
@ -301,7 +263,6 @@ class MainActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
Timber.d("onResume")
viewModel.appResume()
lifecycleScope.launchDefault {
screensaverService.pulse()
}
@ -310,7 +271,7 @@ class MainActivity : AppCompatActivity() {
override fun onRestart() {
super.onRestart()
Timber.d("onRestart")
viewModel.appStart(null)
viewModel.appStart()
if (!playExternalViewModel.launched.value) {
// If restarting during playback that is not external, go back a page
val lastDest = navigationManager.backStack.lastOrNull()
@ -357,14 +318,10 @@ class MainActivity : AppCompatActivity() {
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
Timber.d("onSaveInstanceState")
try {
val str = json.encodeToString(navigationManager.backStack.toList())
outState.putString(KEY_BACK_STACK, str)
} catch (ex: Exception) {
// Best-effort: some destinations carry types we can't serialize; skip persisting
// the back stack rather than crashing in onSaveInstanceState.
Timber.w(ex, "Could not persist back stack; skipping")
}
val playerBackend =
runBlocking { userPreferencesDataStore.data.firstOrNull() }?.playbackPreferences?.playerBackend
outState.putBoolean(KEY_EXTERNAL_PLAYER, playerBackend == PlayerBackend.EXTERNAL_PLAYER)
}
@ -380,34 +337,67 @@ class MainActivity : AppCompatActivity() {
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
Timber.d("onConfigurationChanged: newConfig=%s", newConfig)
Timber.d("onConfigurationChanged")
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
Timber.v("onNewIntent")
setIntent(intent)
viewModel.appStart(intent)
extractDestination(intent)?.let {
navigationManager.replace(it)
}
}
private fun extractDestination(intent: Intent): Destination? =
intent.let {
val itemId =
it.getStringExtra(INTENT_ITEM_ID)?.toUUIDOrNull()
val type =
it.getStringExtra(INTENT_ITEM_TYPE)?.let(BaseItemKind::fromNameOrNull)
if (itemId != null && type != null) {
val seriesId = it.getStringExtra(INTENT_SERIES_ID)?.toUUIDOrNull()
val seasonId = it.getStringExtra(INTENT_SEASON_ID)?.toUUIDOrNull()
val episodeNumber = it.getIntExtra(INTENT_EPISODE_NUMBER, -1)
val seasonNumber = it.getIntExtra(INTENT_SEASON_NUMBER, -1)
if (seriesId != null && seasonId != null && episodeNumber >= 0 && seasonNumber >= 0) {
Destination.SeriesOverview(
itemId = seriesId,
type = BaseItemKind.SERIES,
seasonEpisode =
SeasonEpisodeIds(
seasonId = seasonId,
seasonNumber = seasonNumber,
episodeId = itemId,
episodeNumber = episodeNumber,
),
)
} else {
Destination.MediaItem(itemId, type)
}
} else {
null
}
}
fun changeDisplayMode(modeId: Int) {
lifecycleScope.launch(WholphinDispatchers.Main + ExceptionHandler(autoToast = true)) {
try {
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 }
}
} catch (ex: Exception) {
Timber.e(ex, "Error switching preferredDisplayModeId to %s", modeId)
Toast
.makeText(this@MainActivity, "Error changing display mode", Toast.LENGTH_SHORT)
.show()
}
}
}
companion object {
const val INTENT_ITEM_ID = "itemId"
const val INTENT_ITEM_TYPE = "itemType"
const val INTENT_SERIES_ID = "seriesId"
const val INTENT_EPISODE_NUMBER = "epNum"
const val INTENT_SEASON_NUMBER = "seaNum"
const val INTENT_SEASON_ID = "seaId"
private const val KEY_BACK_STACK = "backStack"
private const val KEY_EXTERNAL_PLAYER = "extPlayer"
@ -423,69 +413,13 @@ class MainActivityViewModel
@param:ApplicationContext private val context: Context,
private val preferences: DataStore<AppPreferences>,
val serverRepository: ServerRepository,
private val setupNavigationManager: SetupNavigationManager,
private val navigationManager: NavigationManager,
private val navigationManager: SetupNavigationManager,
private val deviceProfileService: DeviceProfileService,
private val backdropService: BackdropService,
private val appUpgradeHandler: AppUpgradeHandler,
private val intentService: IntentService,
) : ViewModel() {
private val mutex = Mutex()
fun appStart(intent: Intent?) {
fun appStart() {
viewModelScope.launchDefault {
mutex.withLock {
try {
val result = intent?.let { intentService.parseIntent(intent) }
when (result) {
is IntentResult.Error -> {
val current = serverRepository.current.value
val destination =
if (current != null) {
SetupDestination.UserList(current.server)
} else {
SetupDestination.ServerList
}
setupNavigationManager.navigateTo(destination)
Timber.e("Error parsing intent: %s", result.message)
showToast(context, result.message)
return@withLock
}
is IntentResult.Target -> {
val current = serverRepository.current.value
if (current != null) {
Timber.i("Received valid intent, switching to AppContent")
if (result.addHomeToBackStack) {
navigationManager.reloadHome()
} else {
navigationManager.backStack.clear()
}
navigationManager.backStack.addAll(result.destinations)
setupNavigationManager.navigateTo(
SetupDestination.AppContent(current),
)
} else {
// This should never happen, but just reset app state if it does
setupNavigationManager.navigateTo(SetupDestination.ServerList)
Timber.e("Error parsing intent, no user is active")
showToast(context, "An error occurred parsing the intent")
}
return@withLock
}
IntentResult.NoOp,
null,
-> {
// No-op, proceed below
}
}
} catch (ex: Exception) {
Timber.e(ex, "Error parsing intent")
}
try {
val needUpgrade = appUpgradeHandler.needUpgrade()
if (needUpgrade) {
@ -502,9 +436,9 @@ class MainActivityViewModel
val prefs =
preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
val profileProtected =
serverRepository.current.value
?.user
?.isProtected == true
serverRepository.current.value?.user?.let {
it.hasPin || it.requireLogin
} == true
if (prefs.signInAutomatically && !profileProtected) {
val current =
serverRepository.restoreSession(
@ -512,48 +446,35 @@ class MainActivityViewModel
prefs.currentUserId?.toUUIDOrNull(),
)
if (current != null) {
if (current.user.isProtected) {
setupNavigationManager.navigateTo(
SetupDestination.UserList(
current.server,
),
)
if (current.user.hasPin || current.user.requireLogin) {
navigationManager.navigateTo(SetupDestination.UserList(current.server))
} else {
// Restored
setupNavigationManager.navigateTo(
SetupDestination.AppContent(
current,
),
)
navigationManager.navigateTo(SetupDestination.AppContent(current))
}
} else {
// Did not restore
setupNavigationManager.navigateTo(SetupDestination.ServerList)
navigationManager.navigateTo(SetupDestination.ServerList)
}
} else {
setupNavigationManager.navigateTo(SetupDestination.Loading)
navigationManager.navigateTo(SetupDestination.Loading)
backdropService.clearBackdrop()
val currentServerId = prefs.currentServerId?.toUUIDOrNull()
if (currentServerId != null) {
val currentServer =
serverRepository.serverDao.getServer(currentServerId)?.server
if (currentServer != null) {
setupNavigationManager.navigateTo(
SetupDestination.UserList(
currentServer,
),
)
navigationManager.navigateTo(SetupDestination.UserList(currentServer))
} else {
setupNavigationManager.navigateTo(SetupDestination.ServerList)
navigationManager.navigateTo(SetupDestination.ServerList)
}
} else {
setupNavigationManager.navigateTo(SetupDestination.ServerList)
navigationManager.navigateTo(SetupDestination.ServerList)
}
}
} catch (ex: Exception) {
Timber.e(ex, "Error during appStart")
setupNavigationManager.navigateTo(SetupDestination.ServerList)
}
navigationManager.navigateTo(SetupDestination.ServerList)
}
}
viewModelScope.launchDefault {
@ -561,19 +482,6 @@ class MainActivityViewModel
deviceProfileService.mediaCodecCapabilitiesTest.supportsAVC()
}
}
fun appResume() {
viewModelScope.launchDefault {
mutex.withLock {
try {
Timber.v("Updating userDto")
serverRepository.updateUserDto()
} catch (ex: Exception) {
Timber.w(ex, "Error during appResume")
}
}
}
}
}
private val Destination?.isPlayback: Boolean

View file

@ -17,7 +17,6 @@ import androidx.compose.runtime.rememberUpdatedState
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.graphics.RectangleShape
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
@ -28,6 +27,7 @@ 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
@ -44,12 +44,12 @@ import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization
fun MainContent(
backStack: MutableList<SetupDestination>,
navigationManager: NavigationManager,
userPreferences: UserPreferences,
appPreferences: AppPreferences,
backdropService: BackdropService,
screensaverService: ScreensaverService,
modifier: Modifier = Modifier,
) {
val preferences by rememberUpdatedState(userPreferences)
val preferences by rememberUpdatedState(UserPreferences(appPreferences))
Surface(
modifier =
modifier
@ -59,7 +59,7 @@ fun MainContent(
// val backStack = rememberNavBackStack(SetupDestination.Loading)
// setupNavigationManager.backStack = backStack
val interfaceCustomization =
remember(userPreferences.appPreferences) { InterfaceCustomization(userPreferences.appPreferences) }
remember(appPreferences) { InterfaceCustomization(appPreferences) }
CompositionLocalProvider(LocalInterfaceCustomization provides interfaceCustomization) {
NavDisplay(
backStack = backStack,
@ -105,7 +105,7 @@ fun MainContent(
mutableStateOf(true)
}
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
if (!userPreferences.appPreferences.signInAutomatically) {
if (!appPreferences.signInAutomatically) {
showContent = false
}
}
@ -137,28 +137,12 @@ fun MainContent(
val screenSaverState by screensaverService.state.collectAsState()
if (screenSaverState.enabled || screenSaverState.enabledTemp) {
AnimatedVisibility(
visible = screenSaverState.show,
enter = ScreensaverService.enterAnimation,
exit = ScreensaverService.exitAnimation,
modifier = Modifier.fillMaxSize(),
screenSaverState.show,
Modifier.fillMaxSize(),
) {
AppScreensaver(userPreferences.appPreferences, Modifier.fillMaxSize())
AppScreensaver(appPreferences, Modifier.fillMaxSize())
}
}
AnimatedVisibility(
visible = screenSaverState.showDim || (screenSaverState.dimEnabled && screenSaverState.show),
enter = ScreensaverService.enterAnimation,
exit = ScreensaverService.exitAnimation,
modifier = Modifier.fillMaxSize(),
) {
val alpha =
userPreferences.appPreferences.interfacePreferences.screensaverPreference.dimPercent / 100f
Box(
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = alpha)),
)
}
}
}
}

View file

@ -15,8 +15,6 @@ import org.acra.ReportField
import org.acra.config.dialog
import org.acra.data.StringFormat
import org.acra.ktx.initAcra
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.model.ServerVersion
import timber.log.Timber
import javax.inject.Inject
@ -122,7 +120,5 @@ class WholphinApplication :
companion object {
lateinit var instance: WholphinApplication
private set
val minimumServerVersion: ServerVersion = Jellyfin.minimumVersion
}
}

View file

@ -1,8 +1,6 @@
package com.github.damontecres.wholphin
import android.service.dreams.DreamService
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@ -11,7 +9,6 @@ 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.ComposeView
import androidx.datastore.core.DataStore
import androidx.lifecycle.Lifecycle
@ -107,7 +104,6 @@ class WholphinDreamService :
ProvideLocalClock {
val screensaverPrefs = prefs.interfacePreferences.screensaverPreference
val currentItem by itemFlow.collectAsState(null)
Box(Modifier.fillMaxSize()) {
AppScreensaverContent(
currentItem = currentItem,
showClock = screensaverPrefs.showClock,
@ -115,15 +111,6 @@ class WholphinDreamService :
animate = screensaverPrefs.animate,
modifier = Modifier.fillMaxSize(),
)
if (screensaverPrefs.dimEnabled) {
val alpha = screensaverPrefs.dimPercent / 100f
Box(
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = alpha)),
)
}
}
}
}
}

View file

@ -16,7 +16,6 @@ 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.RememberedTab
import com.github.damontecres.wholphin.data.model.SeerrServer
import com.github.damontecres.wholphin.data.model.SeerrUser
import com.github.damontecres.wholphin.ui.components.ViewOptions
@ -47,9 +46,8 @@ import java.util.UUID
ItemTrackModification::class,
SeerrServer::class,
SeerrUser::class,
RememberedTab::class,
],
version = 35,
version = 33,
exportSchema = true,
autoMigrations = [
AutoMigration(3, 4),
@ -66,8 +64,6 @@ import java.util.UUID
AutoMigration(30, 31),
AutoMigration(31, 32),
AutoMigration(32, 33),
AutoMigration(33, 34),
AutoMigration(34, 35),
],
)
@TypeConverters(Converters::class)
@ -85,8 +81,6 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun seerrServerDao(): SeerrServerDao
abstract fun playbackEffectDao(): PlaybackEffectDao
abstract fun rememberedTabDao(): RememberedTabDao
}
class Converters {

View file

@ -27,7 +27,6 @@ sealed interface ExtrasItem {
val subtitle: String?
val isPlayed: Boolean
val playedPercentage: Double
val aspectRatio: Float?
/**
* Represents multiple extras of the same type
@ -40,7 +39,6 @@ sealed interface ExtrasItem {
override val title: String,
override val subtitle: String,
override val isPlayed: Boolean,
override val aspectRatio: Float?,
) : ExtrasItem {
override val destination: Destination =
Destination.ItemGrid(
@ -84,8 +82,6 @@ sealed interface ExtrasItem {
get() = item.played
override val playedPercentage
get() = item.data.userData?.playedPercentage ?: 0.0
override val aspectRatio: Float?
get() = item.aspectRatio
}
}
@ -96,18 +92,18 @@ sealed interface ExtrasItem {
val ExtraType.stringRes: Int
get() =
when (this) {
ExtraType.UNKNOWN -> R.string.other_extras_title
ExtraType.CLIP -> R.string.clips_title
ExtraType.TRAILER -> R.string.trailers_title
ExtraType.BEHIND_THE_SCENES -> R.string.behind_the_scenes_title
ExtraType.DELETED_SCENE -> R.string.deleted_scenes_title
ExtraType.INTERVIEW -> R.string.interviews_title
ExtraType.SCENE -> R.string.scenes_title
ExtraType.SAMPLE -> R.string.samples_title
ExtraType.THEME_SONG -> R.string.theme_songs_title
ExtraType.THEME_VIDEO -> R.string.theme_videos_title
ExtraType.FEATURETTE -> R.string.featurettes_title
ExtraType.SHORT -> R.string.shorts_title
ExtraType.UNKNOWN -> R.string.other_extras
ExtraType.CLIP -> R.string.clips
ExtraType.TRAILER -> R.string.trailers
ExtraType.BEHIND_THE_SCENES -> R.string.behind_the_scenes
ExtraType.DELETED_SCENE -> R.string.deleted_scenes
ExtraType.INTERVIEW -> R.string.interviews
ExtraType.SCENE -> R.string.scenes
ExtraType.SAMPLE -> R.string.samples
ExtraType.THEME_SONG -> R.string.theme_songs
ExtraType.THEME_VIDEO -> R.string.theme_videos
ExtraType.FEATURETTE -> R.string.featurettes
ExtraType.SHORT -> R.string.shorts
}
/**

View file

@ -7,7 +7,7 @@ import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.util.WholphinDispatchers
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream
@ -19,9 +19,6 @@ import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration
/**
* Wrapper for [ItemPlaybackDao] to save [ItemPlayback] objects and also convert them into [ChosenStreams]
*/
@Singleton
class ItemPlaybackRepository
@Inject
@ -31,13 +28,6 @@ class ItemPlaybackRepository
private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
private val streamChoiceService: StreamChoiceService,
) {
/**
* Get the chosen source & stream for the given item
*
* This will be either the explicit choice from the user ([ItemPlayback]) or the default choices
*
* @see getChosenItemFromPlayback
*/
suspend fun getSelectedTracks(
itemId: UUID,
item: BaseItem,
@ -50,11 +40,6 @@ class ItemPlaybackRepository
return getChosenItemFromPlayback(item, itemPlayback, plc, prefs)
}
/**
* Get the chosen source & stream for the given item
*
* This will be either the explicit choice from the provided [ItemPlayback] & [PlaybackLanguageChoice] or the default choices if they are null
*/
fun getChosenItemFromPlayback(
item: BaseItem,
itemPlayback: ItemPlayback?,
@ -101,14 +86,11 @@ class ItemPlaybackRepository
}
}
/**
* Save the choice for a specific media source for an item
*/
suspend fun savePlayVersion(
itemId: UUID,
sourceId: UUID,
): ItemPlayback? =
withContext(WholphinDispatchers.IO) {
withContext(Dispatchers.IO) {
serverRepository.currentUser?.let { user ->
val itemPlayback =
ItemPlayback(
@ -121,11 +103,6 @@ class ItemPlaybackRepository
}
}
/**
* Save the chosen track (index & type) for an item
*
* Additionally, if the item is part of a TV Series, store the language as the preferred one for this series
*/
suspend fun saveTrackSelection(
item: BaseItem,
itemPlayback: ItemPlayback?,
@ -203,9 +180,6 @@ class ItemPlaybackRepository
return toSave.copy(rowId = id)
}
/**
* Wrapper for [ItemPlaybackDao.getTrackModifications] to get the [ItemTrackModification] for an item, if any
*/
suspend fun getTrackModifications(
itemId: UUID,
trackIndex: Int,
@ -214,9 +188,6 @@ class ItemPlaybackRepository
itemPlaybackDao.getTrackModifications(userId, itemId, trackIndex)
}
/**
* Create and save a [ItemTrackModification] via [ItemPlaybackDao.saveItem]
*/
suspend fun saveTrackModifications(
itemId: UUID,
trackIndex: Int,
@ -235,9 +206,6 @@ class ItemPlaybackRepository
}
}
/**
* Clear the user's source/stream choices for an item
*/
suspend fun deleteChosenStreams(chosenStreams: ChosenStreams?) {
Timber.d("deleteChosenStreams: %s", chosenStreams)
chosenStreams?.plc?.let {
@ -251,9 +219,6 @@ class ItemPlaybackRepository
}
}
/**
* Info about which media source and streams the user/app has chosen for playback
*/
data class ChosenStreams(
val itemPlayback: ItemPlayback?,
val plc: PlaybackLanguageChoice?,

View file

@ -9,7 +9,6 @@ import androidx.room.Update
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinServerUsers
import com.github.damontecres.wholphin.data.model.JellyfinUser
import kotlinx.coroutines.flow.Flow
import java.util.UUID
@Dao
@ -57,12 +56,6 @@ interface JellyfinServerDao {
userId: UUID,
): JellyfinUser?
@Query("SELECT * FROM users WHERE serverId = :serverId AND id = :userId")
fun getUserFlow(
serverId: UUID,
userId: UUID,
): Flow<JellyfinUser?>
@Query("DELETE FROM servers WHERE id = :serverId")
fun deleteServer(serverId: UUID)

View file

@ -1,22 +0,0 @@
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.RememberedTab
@Dao
interface RememberedTabDao {
@Query("SELECT * from RememberedTab WHERE userId=:userId AND itemId=:itemId")
suspend fun getRememberedTab(
userId: Int,
itemId: String,
): RememberedTab?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun save(item: RememberedTab): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun saveAll(items: List<RememberedTab>)
}

View file

@ -9,17 +9,13 @@ 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.toServerString
import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import org.jellyfin.sdk.Jellyfin
@ -60,18 +56,7 @@ class ServerRepository
val currentServer: JellyfinServer? get() = _current.value?.server
val currentServerFlow: Flow<JellyfinServer?> get() = _current.map { it?.server }
val currentUser: JellyfinUser? get() = _current.value?.user
@OptIn(ExperimentalCoroutinesApi::class)
val currentUserFlow: Flow<JellyfinUser?>
get() =
_current
.flatMapLatest { user ->
if (user?.user != null) {
serverDao.getUserFlow(user.user.serverId, user.user.id)
} else {
flow { emit(null) }
}
}
val currentUserFlow: Flow<JellyfinUser?> get() = _current.map { it?.user }
/**
* Adds a server to the app database and updated the [ApiClient] to the server's URL
@ -124,7 +109,7 @@ class ServerRepository
}.build()
}
val currentUser = CurrentUser(updatedServer, updatedUser)
withContext(WholphinDispatchers.Main) {
withContext(Dispatchers.Main) {
_current.value = currentUser
_currentUserDto.value = userDto
}
@ -237,7 +222,7 @@ class ServerRepository
suspend fun removeUser(user: JellyfinUser) {
if (current.value?.user?.id == user.id) {
withContext(WholphinDispatchers.Main) {
withContext(Dispatchers.Main) {
_current.value = null
}
userPreferencesDataStore.updateData {
@ -256,7 +241,7 @@ class ServerRepository
suspend fun removeServer(server: JellyfinServer) {
if (current.value?.server?.id == server.id) {
withContext(WholphinDispatchers.Main) {
withContext(Dispatchers.Main) {
_current.value = null
}
userPreferencesDataStore.updateData {
@ -313,16 +298,6 @@ class ServerRepository
response.content
}
/**
* Update [currentUserDto] by querying the server
*/
suspend fun updateUserDto() {
val userDto by apiClient.userApi.getCurrentUser()
_currentUserDto.update {
if (it?.id == userDto.id && currentUser?.id == userDto.id) userDto else it
}
}
companion object {
fun getServerSharedPreferences(context: Context): SharedPreferences =
context.getSharedPreferences(

View file

@ -1,287 +0,0 @@
package com.github.damontecres.wholphin.data.filter
import androidx.annotation.StringRes
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.api.seerr.SearchApi
import com.github.damontecres.wholphin.api.seerr.SearchApi.CertificationModeDiscoverTvGet
import com.github.damontecres.wholphin.api.seerr.model.DiscoverMoviesGet200Response
import com.github.damontecres.wholphin.api.seerr.model.DiscoverTvGet200Response
import com.github.damontecres.wholphin.api.seerr.model.TvShowStatus
import com.github.damontecres.wholphin.ui.data.flip
import kotlinx.serialization.Serializable
import org.jellyfin.sdk.model.api.SortOrder
import java.time.format.DateTimeFormatter
import kotlin.time.Duration
private val dateFormatter = DateTimeFormatter.ofPattern("yyyy-M-d")
enum class DiscoverSort(
val key: String,
@param:StringRes val stringRes: Int,
val tvKey: String = key,
) {
POPULARITY("popularity", R.string.popularity),
RELEASE_DATE("release_date", R.string.sort_by_date_released, "first_air_date"),
TMDB_VOTE("vote_average", R.string.community_rating),
ALPHABETICAL("original_title", R.string.sort_by_name),
}
private val SortOrder.key get() =
when (this) {
SortOrder.ASCENDING -> "asc"
SortOrder.DESCENDING -> "desc"
}
@Serializable
data class DiscoverSortAndDirection(
val sort: DiscoverSort = DiscoverSort.POPULARITY,
val direction: SortOrder = SortOrder.DESCENDING,
) {
fun key(isTv: Boolean) = if (isTv) "${sort.tvKey}.${direction.key}" else "${sort.key}.${direction.key}"
fun flip() = copy(direction = direction.flip())
}
@Serializable
data class DiscoverFilter(
// /api/v1/languages
val language: String? = null,
val genreIds: List<Int>? = null,
val networkIds: List<Int>? = null,
// /api/v1/search/keyword
val keywordIds: List<Int>? = null,
val excludeKeywordIds: List<Int>? = null,
val sortBy: DiscoverSortAndDirection? = null,
// val firstAirDateGte: LocalDate? = null,
// val firstAirDateLte: LocalDate? = null,
val withRuntimeGte: Duration? = null,
val withRuntimeLte: Duration? = null,
val voteAverageGte: Int? = null,
val voteAverageLte: Int? = null,
val voteCountGte: Int? = null,
val voteCountLte: Int? = null,
// /api/v1/watchproviders/regions
val watchRegion: String? = null, // US
// networkIds?
// val watchProviders: String? = null,
val status: List<TvShowStatus>? = null,
val certification: List<String>? = null,
// val certificationGte: String? = null,
// val certificationLte: String? = null,
// val certificationCountry: String? = null, // US
// val certificationMatchExact: Boolean? = null,
) {
/**
* Returns how many of filters are actually being used in this
*/
fun countFilters(filterOptions: List<DiscoverFilterBy<*>>): Int {
var count = 0
filterOptions.forEach {
if (it.get(this) != null) count++
}
return count
}
/**
* Clear all the values for the given filters
*/
fun delete(filterOptions: List<DiscoverFilterBy<*>>): DiscoverFilter {
var newFilter = this
filterOptions.forEach {
newFilter = it.set(null, newFilter)
}
return newFilter
}
suspend fun discoverTv(
searchApi: SearchApi,
page: Int,
): DiscoverTvGet200Response =
searchApi.discoverTvGet(
page = page,
language = language,
genre = genreIds?.joinToString(",") { it.toString() },
network = null,
keywords = keywordIds?.joinToString(",") { it.toString() },
excludeKeywords = excludeKeywordIds?.joinToString(",") { it.toString() },
sortBy = sortBy?.key(true),
// firstAirDateGte = firstAirDateGte?.let { dateFormatter.format(it) },
// firstAirDateLte = firstAirDateLte?.let { dateFormatter.format(it) },
withRuntimeGte = withRuntimeGte?.inWholeMinutes?.toInt(),
withRuntimeLte = withRuntimeLte?.inWholeMinutes?.toInt(),
voteAverageGte = voteAverageGte,
voteAverageLte = voteAverageLte,
voteCountGte = voteCountGte,
voteCountLte = voteCountLte,
// TODO
// watchRegion = watchRegion,
watchRegion = networkIds?.let { "US" },
watchProviders = networkIds?.joinToString(",") { it.toString() },
status = status?.joinToString("|") { it.ordinal.toString() },
certification = certification?.joinToString("|"),
// certificationGte = certificationGte,
// certificationLte = certificationLte,
certificationCountry = certification?.let { "US" },
certificationMode = certification?.let { CertificationModeDiscoverTvGet.EXACT },
)
suspend fun discoverMovies(
searchApi: SearchApi,
page: Int,
): DiscoverMoviesGet200Response =
searchApi.discoverMoviesGet(
page = page,
language = language,
genre = genreIds?.joinToString(",") { it.toString() },
// network = null,
keywords = keywordIds?.joinToString(",") { it.toString() },
excludeKeywords = excludeKeywordIds?.joinToString(",") { it.toString() },
sortBy = sortBy?.key(false),
// firstAirDateGte = firstAirDateGte?.let { dateFormatter.format(it) },
// firstAirDateLte = firstAirDateLte?.let { dateFormatter.format(it) },
withRuntimeGte = withRuntimeGte?.inWholeMinutes?.toInt(),
withRuntimeLte = withRuntimeLte?.inWholeMinutes?.toInt(),
voteAverageGte = voteAverageGte,
voteAverageLte = voteAverageLte,
voteCountGte = voteCountGte,
voteCountLte = voteCountLte,
// TODO
// watchRegion = watchRegion,
watchRegion = networkIds?.let { "US" },
watchProviders = networkIds?.joinToString(",") { it.toString() },
// status = status,
certification = certification?.joinToString("|"),
// certificationGte = certificationGte,
// certificationLte = certificationLte,
certificationCountry = certification?.let { "US" },
certificationMode = certification?.let { SearchApi.CertificationModeDiscoverMoviesGet.EXACT },
)
}
val discoverMovieFilters =
listOf(
DiscoverMovieGenreFilter,
DiscoverMovieStudiosFilter,
DiscoverRatingFilter,
DiscoverMovieContentRatingFilter,
)
val discoverTvFilters =
listOf(
DiscoverTvGenreFilter,
DiscoverTvStudiosFilter,
DiscoverRatingFilter,
DiscoverTvContentRatingFilter,
DiscoverTvStatusFilter,
)
/**
* A way to filter discover
*
* Gets and sets values within a [DiscoverFilter]
*/
sealed interface DiscoverFilterBy<T> : FilterBy<DiscoverFilter, T>
data object DiscoverMovieGenreFilter : DiscoverFilterBy<List<Int>> {
override val stringRes: Int = R.string.genres
override val supportMultiple: Boolean = true
override fun get(filter: DiscoverFilter): List<Int>? = filter.genreIds
override fun set(
value: List<Int>?,
filter: DiscoverFilter,
): DiscoverFilter = filter.copy(genreIds = value)
}
data object DiscoverTvGenreFilter : DiscoverFilterBy<List<Int>> {
override val stringRes: Int = R.string.genres
override val supportMultiple: Boolean = true
override fun get(filter: DiscoverFilter): List<Int>? = filter.genreIds
override fun set(
value: List<Int>?,
filter: DiscoverFilter,
): DiscoverFilter = filter.copy(genreIds = value)
}
data object DiscoverMovieStudiosFilter : DiscoverFilterBy<List<Int>> {
override val stringRes: Int = R.string.studios
override val supportMultiple: Boolean = true
override fun get(filter: DiscoverFilter): List<Int>? = filter.networkIds
override fun set(
value: List<Int>?,
filter: DiscoverFilter,
): DiscoverFilter = filter.copy(networkIds = value)
}
data object DiscoverTvStudiosFilter : DiscoverFilterBy<List<Int>> {
override val stringRes: Int = R.string.studios
override val supportMultiple: Boolean = true
override fun get(filter: DiscoverFilter): List<Int>? = filter.networkIds
override fun set(
value: List<Int>?,
filter: DiscoverFilter,
): DiscoverFilter = filter.copy(networkIds = value)
}
data object DiscoverTvStatusFilter : DiscoverFilterBy<List<TvShowStatus>> {
override val stringRes: Int = R.string.production_status
override val supportMultiple: Boolean = true
override fun get(filter: DiscoverFilter): List<TvShowStatus>? = filter.status
override fun set(
value: List<TvShowStatus>?,
filter: DiscoverFilter,
): DiscoverFilter = filter.copy(status = value)
}
data object DiscoverMovieContentRatingFilter : DiscoverFilterBy<List<String>> {
override val stringRes: Int = R.string.official_rating
override val supportMultiple: Boolean = true
override fun get(filter: DiscoverFilter): List<String>? = filter.certification
override fun set(
value: List<String>?,
filter: DiscoverFilter,
): DiscoverFilter = filter.copy(certification = value)
}
data object DiscoverTvContentRatingFilter : DiscoverFilterBy<List<String>> {
override val stringRes: Int = R.string.official_rating
override val supportMultiple: Boolean = true
override fun get(filter: DiscoverFilter): List<String>? = filter.certification
override fun set(
value: List<String>?,
filter: DiscoverFilter,
): DiscoverFilter = filter.copy(certification = value)
}
data object DiscoverRatingFilter : DiscoverFilterBy<Int> {
override val stringRes: Int = R.string.community_rating
override val supportMultiple: Boolean = true
override fun get(filter: DiscoverFilter): Int? = filter.voteAverageGte
override fun set(
value: Int?,
filter: DiscoverFilter,
): DiscoverFilter = filter.copy(voteAverageGte = value)
}

View file

@ -77,29 +77,24 @@ val DefaultPlaylistItemsOptions =
)
/**
* Abstract way to filter data
* A way to filter libraries
*
* Gets and sets values within a [GetItemsFilter]
*/
sealed interface FilterBy<FilterType, DataType> {
sealed interface ItemFilterBy<T> {
@get:StringRes
val stringRes: Int
val supportMultiple: Boolean
fun get(filter: FilterType): DataType?
fun get(filter: GetItemsFilter): T?
fun set(
value: DataType?,
filter: FilterType,
): FilterType
value: T?,
filter: GetItemsFilter,
): GetItemsFilter
}
/**
* A way to filter jellyfin libraries
*
* Gets and sets values within a [GetItemsFilter]
*/
sealed interface ItemFilterBy<T> : FilterBy<GetItemsFilter, T>
data object GenreFilter : ItemFilterBy<List<UUID>> {
override val stringRes: Int = R.string.genres

View file

@ -1,27 +0,0 @@
package com.github.damontecres.wholphin.data.filter
import com.github.damontecres.wholphin.data.model.GetItemsFilter
interface SearchFilter<T : SearchFilter<T>> {
/**
* Returns how many of filters are actually being used in this [GetItemsFilter]
*/
fun countFilters(filterOptions: List<FilterBy<T, *>>): Int {
var count = 0
filterOptions.forEach {
if (it.get(this as T) != null) count++
}
return count
}
/**
* Clear all the values for the given filters
*/
fun delete(filterOptions: List<FilterBy<T, *>>): T {
var newFilter = this
filterOptions.forEach {
newFilter = it.set(null, newFilter as T)
}
return newFilter as T
}
}

View file

@ -5,18 +5,13 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.ui.abbreviateNumber
import com.github.damontecres.wholphin.ui.detail.CardGridItem
import com.github.damontecres.wholphin.ui.detail.music.artistsString
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.dot
import com.github.damontecres.wholphin.ui.formatDateTime
import com.github.damontecres.wholphin.ui.formatDuration
import com.github.damontecres.wholphin.ui.formatEpisodeNumber
import com.github.damontecres.wholphin.ui.getDateFormatter
import com.github.damontecres.wholphin.ui.joinNotBlank
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.playable
import com.github.damontecres.wholphin.ui.roundMinutes
@ -24,13 +19,11 @@ import com.github.damontecres.wholphin.ui.seasonEpisode
import com.github.damontecres.wholphin.ui.seasonEpisodePadded
import com.github.damontecres.wholphin.ui.seriesProductionYears
import com.github.damontecres.wholphin.ui.timeRemaining
import com.github.damontecres.wholphin.ui.toServerString
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.extensions.ticks
import java.util.Locale
import java.util.UUID
@ -55,7 +48,7 @@ data class BaseItem(
get() = type.playable
override val sortName: String
get() = data.alphabetSortName
get() = data.sortName ?: data.name ?: ""
val type get() = data.type
@ -63,14 +56,14 @@ data class BaseItem(
val title get() = if (type == BaseItemKind.EPISODE) data.seriesName else name
val subtitle: String? by lazy {
val subtitle
get() =
when (type) {
BaseItemKind.EPISODE -> listOf(data.seasonEpisode, name).joinNotBlank(" - ")
BaseItemKind.EPISODE -> data.seasonEpisode + " - " + name
BaseItemKind.SERIES -> data.seriesProductionYears
BaseItemKind.AUDIO -> listOf(data.album, artistsString).joinNotBlank(" - ")
BaseItemKind.AUDIO -> listOfNotNull(data.album, artistsString).joinToString(" - ")
else -> data.productionYear?.toString()
}
}
val subtitleLong: String? by lazy {
if (type == BaseItemKind.EPISODE) {
@ -78,7 +71,7 @@ data class BaseItem(
add(data.seasonEpisodePadded)
add(data.name)
add(data.premiereDate?.let { formatDateTime(it) })
}.joinNotBlank(" - ")
}.filterNotNull().joinToString(" - ")
} else {
data.productionYear?.toString()
}
@ -98,12 +91,7 @@ data class BaseItem(
val favorite get() = data.userData?.isFavorite ?: false
val timeRemainingOrRuntime: Duration?
get() =
when (type) {
BaseItemKind.PROGRAM -> null
else -> data.timeRemaining ?: data.runTimeTicks?.ticks
}
val timeRemainingOrRuntime: Duration? get() = data.timeRemaining ?: data.runTimeTicks?.ticks
/**
* Contains pre computed UI elements that would be expensive to create on the main thread
@ -112,7 +100,7 @@ data class BaseItem(
val ui =
BaseItemUi(
episodeCornerText =
data.indexNumber?.let { formatEpisodeNumber(it) }
data.indexNumber?.let { "E$it" }
?: data.premiereDate?.let(::formatDateTime),
episodeUnplayedCornerText =
if (type == BaseItemKind.SERIES ||
@ -120,7 +108,7 @@ data class BaseItem(
type == BaseItemKind.EPISODE ||
type == BaseItemKind.BOX_SET
) {
data.indexNumber?.let { formatEpisodeNumber(it) }
data.indexNumber?.let { "E$it" }
?: data.userData
?.unplayedItemCount
?.takeIf { it > 0 }
@ -148,36 +136,16 @@ data class BaseItem(
} else if (type == BaseItemKind.BOX_SET) {
data.productionYear?.let { add(it.toString()) }
data.childCount?.let { add("$it items") }
} else if (type == BaseItemKind.PROGRAM) {
data.channelName?.let(::add)
if (data.isSeries == true) {
// TV episode
data.seasonEpisode?.let(::add)
data.premiereDate?.let {
add(getDateFormatter().format(it))
}
} else {
data.productionYear?.let { add(it.toString()) }
}
} else {
data.productionYear?.let { add(it.toString()) }
}
data.runTimeTicks
?.ticks
?.roundMinutes
?.takeIf { it > Duration.ZERO }
?.let { add(WholphinApplication.instance.resources.formatDuration(it)) }
?.let { add(it.toString()) }
data.timeRemaining
?.roundMinutes
?.let {
val resources = WholphinApplication.instance.resources
add(
resources.getString(
R.string.time_left,
resources.formatDuration(it),
),
)
}
?.let { add("$it left") }
}
details.forEachIndexed { index, string ->
append(string)
@ -229,38 +197,25 @@ data class BaseItem(
*/
fun destination(index: Int? = null): Destination {
if (destinationOverride != null) return destinationOverride
if (data.extraType != null || type == BaseItemKind.TRAILER) {
// Extras including trailers should always play directly
return Destination.Playback(id, 0)
}
val result =
// Redirect episodes & seasons to their series if possible
when (type) {
BaseItemKind.EPISODE -> {
val seasonId = data.seasonId
val seriesId = data.seriesId
if (seasonId != null && seriesId != null) {
data.seasonId?.let { seasonId ->
Destination.SeriesOverview(
seriesId,
data.seriesId!!,
BaseItemKind.SERIES,
SeasonEpisodeIds(seasonId, data.parentIndexNumber, id, indexNumber),
)
} else {
Destination.MediaItem(this)
}
} ?: Destination.MediaItem(this)
}
BaseItemKind.SEASON -> {
val seriesId = data.seriesId
if (seriesId != null) {
Destination.SeriesOverview(
seriesId,
data.seriesId!!,
BaseItemKind.SERIES,
SeasonEpisodeIds(id, indexNumber, null, null),
)
} else {
Destination.MediaItem(this)
}
}
BaseItemKind.TV_CHANNEL -> {
@ -282,16 +237,6 @@ data class BaseItem(
}
}
BaseItemKind.AUDIO -> {
data.albumId?.let { albumId ->
Destination.MediaItem(
itemId = albumId,
type = BaseItemKind.MUSIC_ALBUM,
initialSongId = id,
)
} ?: Destination.MediaItem(this)
}
else -> {
Destination.MediaItem(this)
}
@ -315,9 +260,6 @@ data class BaseItem(
val BaseItemDto.aspectRatioFloat: Float? get() = width?.let { w -> height?.let { h -> w.toFloat() / h.toFloat() } }
/** The server strips leading articles (eg "The ") from [BaseItemDto.sortName]. */
val BaseItemDto.alphabetSortName: String get() = sortName ?: name ?: ""
@Immutable
data class BaseItemUi(
val episodeCornerText: String?,
@ -341,7 +283,6 @@ fun createGenreDestination(
parentId: UUID,
parentName: String?,
includeItemTypes: List<BaseItemKind>?,
collectionType: CollectionType,
) = Destination.FilteredCollection(
itemId = parentId,
parentType = BaseItemKind.GENRE,
@ -357,11 +298,9 @@ fun createGenreDestination(
genres = listOf(genreId),
includeItemTypes = includeItemTypes,
),
useSavedLibraryDisplayInfo = true,
libraryDisplayInfoIdOverride = "${parentId.toServerString()}_genres",
useSavedLibraryDisplayInfo = false,
),
recursive = true,
collectionType = collectionType,
)
fun createStudioDestination(
@ -385,11 +324,9 @@ fun createStudioDestination(
studios = listOf(studioId),
includeItemTypes = includeItemTypes,
),
useSavedLibraryDisplayInfo = true,
libraryDisplayInfoIdOverride = "${parentId.toServerString()}_studios",
useSavedLibraryDisplayInfo = false,
),
recursive = true,
collectionType = CollectionType.UNKNOWN,
)
val BaseItem.studioNames get() = data.studios?.mapNotNull { it.name }.orEmpty()

View file

@ -1,30 +1,38 @@
package com.github.damontecres.wholphin.data.model
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.extensions.ticks
import java.util.UUID
import kotlin.time.Duration
/**
* Represents a chapter within a video
*/
data class Chapter(
val itemId: UUID,
val name: String?,
val position: Duration,
val tag: String?,
val index: Int,
val imageUrl: String?,
) {
companion object {
fun fromDto(dto: BaseItemDto): List<Chapter> =
fun fromDto(
dto: BaseItemDto,
api: ApiClient,
): List<Chapter> =
dto.chapters
?.mapIndexed { index, chapter ->
Chapter(
chapter.name,
chapter.startPositionTicks.ticks,
chapter.imageTag?.let {
api.imageApi.getItemImageUrl(
itemId = dto.id,
name = chapter.name,
position = chapter.startPositionTicks.ticks,
tag = chapter.imageTag,
index = index,
imageType = ImageType.CHAPTER,
tag = it,
imageIndex = index,
)
},
)
}?.sortedBy { it.position }
.orEmpty()

View file

@ -56,14 +56,12 @@ enum class SeerrItemType(
enum class SeerrAvailability(
val status: Int,
) {
// https://github.com/seerr-team/seerr/blob/develop/server/constants/media.ts#L14
UNKNOWN(1),
PENDING(2),
PROCESSING(3),
PARTIALLY_AVAILABLE(4),
AVAILABLE(5),
BLOCKLISTED(6),
DELETED(7),
DELETED(6),
;
companion object {
@ -71,23 +69,6 @@ enum class SeerrAvailability(
}
}
@Serializable
enum class RequestStatus(
val status: Int,
) {
UNKNOWN(0),
PENDING(1),
APPROVED(2),
DECLINED(3),
FAILURE(4),
COMPLETED(5),
;
companion object {
fun from(status: Int?) = RequestStatus.entries.firstOrNull { it.status == status } ?: UNKNOWN
}
}
/**
* An item provided by a discovery service (ie Seerr). It may exist on the JF server as well, see [availability].
*/
@ -103,7 +84,6 @@ data class DiscoverItem(
@Serializable(LocalDateSerializer::class) val releaseDate: LocalDate?,
val posterUrl: String?,
val backDropUrl: String?,
val logoUrl: String?,
val jellyfinItemId: UUID?,
) : CardGridItem {
override val gridId: String get() = id.toString()
@ -112,7 +92,13 @@ data class DiscoverItem(
val destination: Destination
get() {
val jfType = type.baseItemKind
val jfType =
when (type) {
SeerrItemType.MOVIE -> BaseItemKind.MOVIE
SeerrItemType.TV -> BaseItemKind.SERIES
SeerrItemType.PERSON -> BaseItemKind.PERSON
SeerrItemType.UNKNOWN -> null
}
return if (jellyfinItemId != null && jfType != null) {
Destination.MediaItem(
itemId = jellyfinItemId,

View file

@ -9,7 +9,6 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.VideoType
import org.jellyfin.sdk.model.api.request.GetArtistsRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
import org.jellyfin.sdk.model.serializer.UUIDSerializer
@ -23,13 +22,9 @@ data class CollectionFolderFilter(
val nameOverride: String? = null,
val filter: GetItemsFilter = GetItemsFilter(),
/**
* Whether to use the library's saved sort & filter
* Whether to use the libray's saved sort & filter
*/
val useSavedLibraryDisplayInfo: Boolean = true,
/**
* Use a different ID for the library's saved sort & filter, such as for library's genres
*/
val libraryDisplayInfoIdOverride: String? = null,
)
/**
@ -139,22 +134,6 @@ data class GetItemsFilter(
isFavorite = favorite,
)
fun applyTo(req: GetArtistsRequest) =
req.copy(
minCommunityRating = minCommunityRating,
isFavorite = favorite,
genreIds = genres,
personIds = persons,
studioIds = studios,
tags = tags,
officialRatings = officialRatings,
years =
buildSet {
years?.letNotEmpty(::addAll)
decades?.forEach { addAll(it..<(it + 10)) }
},
)
/**
* Merge another [GetItemsFilter] onto this one, replacing only unset values
*/
@ -179,5 +158,4 @@ data class GetItemsFilter(
enum class GetItemsFilterOverride {
NONE,
PERSON,
ARTIST,
}

View file

@ -109,7 +109,15 @@ sealed interface HomeRowConfig {
@SerialName("Favorite")
data class Favorite(
val kind: BaseItemKind,
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
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)
}
@ -235,7 +243,7 @@ data class HomeRowViewOptions(
val liveTvDefault =
HomeRowViewOptions(
heightDp = Cards.HEIGHT_LIVE_TV,
heightDp = 96,
aspectRatio = AspectRatio.WIDE,
contentScale = PrefContentScale.FIT,
)

View file

@ -11,8 +11,6 @@ import androidx.room.Index
import androidx.room.PrimaryKey
import androidx.room.Relation
import com.github.damontecres.wholphin.data.ZonedDateTimeSerializer
import com.github.damontecres.wholphin.preferences.SubtitleModePreference
import com.github.damontecres.wholphin.preferences.UserProfileSettings
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
@ -67,17 +65,11 @@ data class JellyfinUser(
val requireLogin: Boolean = false,
val lastUsed: ZonedDateTime? = null,
val uiLanguage: String? = null,
@Embedded val appPreferences: JellyfinUserPreferences = JellyfinUserPreferences(),
) {
val hasPin: Boolean get() = pin.isNotNullOrBlank()
val isProtected: Boolean get() = hasPin || requireLogin
override fun toString(): String =
"JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, lastUsed=$lastUsed, " +
"accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()}), " +
"requireLogin=$requireLogin, lastUsed=$lastUsed, uiLanguage=$uiLanguage, " +
"appPreferences=$appPreferences"
"JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, lastUsed=$lastUsed, accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()})"
}
/**
@ -91,13 +83,3 @@ data class JellyfinServerUsers(
)
val users: List<JellyfinUser>,
)
@Serializable
data class JellyfinUserPreferences(
@ColumnInfo(defaultValue = "")
val preferredAudioLanguage: String = UserProfileSettings.USE_USER_PROFILE,
@ColumnInfo(defaultValue = "")
val preferredSubtitleLanguage: String = UserProfileSettings.USE_USER_PROFILE,
@ColumnInfo(defaultValue = "USE_USER_PROFILE")
val subtitleMode: SubtitleModePreference = SubtitleModePreference.USE_USER_PROFILE,
)

View file

@ -1,5 +1,8 @@
package com.github.damontecres.wholphin.data.model
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.setValue
import org.jellyfin.sdk.model.api.MediaType
import java.util.UUID
@ -8,13 +11,38 @@ import java.util.UUID
*
* This is not the same thing as a Jellyfin server playlist
*/
data class Playlist(
val items: List<PlaylistItem>,
class Playlist(
items: List<PlaylistItem>,
startIndex: Int = 0,
) {
val items = items.subList(startIndex, items.size)
var index by mutableIntStateOf(0)
fun hasPrevious(): Boolean = index > 0
fun hasNext(): Boolean = (index + 1) < items.size
fun getPreviousAndReverse(): PlaylistItem = items[--index]
fun getAndAdvance(): PlaylistItem = items[++index]
fun peek(): PlaylistItem? = items.getOrNull(index + 1)
fun upcomingItems(): List<PlaylistItem> = items.subList(index + 1, items.size)
fun advanceTo(id: UUID): PlaylistItem? {
while (hasNext()) {
val potential = getAndAdvance()
if (potential.id == id) {
return potential
}
}
return null
}
companion object {
const val MAX_SIZE = 100
fun fromMedia(list: List<BaseItem>) = Playlist(list.map { PlaylistItem.Media(it) })
}
}

View file

@ -1,24 +0,0 @@
package com.github.damontecres.wholphin.data.model
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
@Entity(
foreignKeys = [
ForeignKey(
entity = JellyfinUser::class,
parentColumns = arrayOf("rowId"),
childColumns = arrayOf("userId"),
onDelete = ForeignKey.CASCADE,
onUpdate = ForeignKey.CASCADE,
),
],
indices = [Index("userId", "itemId", unique = true)],
primaryKeys = ["userId", "itemId"],
)
data class RememberedTab(
val userId: Int,
val itemId: String,
val index: Int,
)

View file

@ -7,7 +7,6 @@ import com.github.damontecres.wholphin.services.SeerrUserConfig
*/
enum class SeerrPermission(
private val flag: Int,
vararg val parents: SeerrPermission,
) {
// Source: https://github.com/seerr-team/seerr/blob/develop/server/lib/permissions.ts
NONE(0),
@ -18,28 +17,28 @@ enum class SeerrPermission(
REQUEST(32),
VOTE(64),
AUTO_APPROVE(128),
AUTO_APPROVE_MOVIE(256, AUTO_APPROVE),
AUTO_APPROVE_TV(512, AUTO_APPROVE),
AUTO_APPROVE_MOVIE(256),
AUTO_APPROVE_TV(512),
REQUEST_4K(1024),
REQUEST_4K_MOVIE(2048, REQUEST_4K),
REQUEST_4K_TV(4096, REQUEST_4K),
REQUEST_ADVANCED(8192, MANAGE_REQUESTS),
REQUEST_VIEW(16384, MANAGE_REQUESTS),
REQUEST_4K_MOVIE(2048),
REQUEST_4K_TV(4096),
REQUEST_ADVANCED(8192),
REQUEST_VIEW(16384),
AUTO_APPROVE_4K(32768),
AUTO_APPROVE_4K_MOVIE(65536, AUTO_APPROVE_4K),
AUTO_APPROVE_4K_TV(131072, AUTO_APPROVE_4K),
REQUEST_MOVIE(262144, REQUEST),
REQUEST_TV(524288, REQUEST),
AUTO_APPROVE_4K_MOVIE(65536),
AUTO_APPROVE_4K_TV(131072),
REQUEST_MOVIE(262144),
REQUEST_TV(524288),
MANAGE_ISSUES(1048576),
VIEW_ISSUES(2097152, MANAGE_ISSUES),
CREATE_ISSUES(4194304, MANAGE_ISSUES),
VIEW_ISSUES(2097152),
CREATE_ISSUES(4194304),
AUTO_REQUEST(8388608),
AUTO_REQUEST_MOVIE(16777216, AUTO_REQUEST),
AUTO_REQUEST_TV(33554432, AUTO_REQUEST),
RECENT_VIEW(67108864, MANAGE_REQUESTS),
WATCHLIST_VIEW(134217728, MANAGE_REQUESTS),
MANAGE_BLOCKLIST(268435456),
VIEW_BLOCKLIST(1073741824, MANAGE_BLOCKLIST),
AUTO_REQUEST_MOVIE(16777216),
AUTO_REQUEST_TV(33554432),
RECENT_VIEW(67108864),
WATCHLIST_VIEW(134217728),
MANAGE_BLACKLIST(268435456),
VIEW_BLACKLIST(1073741824),
;
internal fun hasPermission(permissions: Int) = flag.and(permissions) == flag
@ -49,8 +48,5 @@ enum class SeerrPermission(
* Check whether the user has the given permissions (or is an admin)
*/
fun SeerrUserConfig?.hasPermission(permission: SeerrPermission): Boolean {
if (this?.permissions == null) return false
return permission.hasPermission(permissions) ||
permission.parents.any { hasPermission(it) } ||
SeerrPermission.ADMIN.hasPermission(permissions)
return permission.hasPermission(this?.permissions ?: return false) || SeerrPermission.ADMIN.hasPermission(permissions)
}

View file

@ -53,10 +53,7 @@ sealed interface AppPreference<Pref, T> {
value: T?,
): String? = null
fun validate(
prefs: Pref,
value: T,
): PreferenceValidation = PreferenceValidation.Valid
fun validate(value: T): PreferenceValidation = PreferenceValidation.Valid
companion object {
val SkipForward =
@ -218,16 +215,15 @@ sealed interface AppPreference<Pref, T> {
)
val HomeClickToPlay =
AppChoicePreference<AppPreferences, Boolean>(
AppSwitchPreference<AppPreferences>(
title = R.string.continue_watching_click_behavior,
defaultValue = false,
getter = { it.homePagePreferences.clickToPlay },
setter = { prefs, value ->
prefs.updateHomePagePreferences { clickToPlay = value }
},
displayValues = R.array.home_click_to_play_options,
indexToValue = { it != 0 },
valueToIndex = { if (it) 1 else 0 },
summaryOn = R.string.continue_watching_click_summary_on,
summaryOff = R.string.continue_watching_click_summary_off,
)
val PlayThemeMusic =
@ -395,25 +391,18 @@ sealed interface AppPreference<Pref, T> {
defaultValue = true,
getter = { it.playbackPreferences.overrides.ac3Supported },
setter = { prefs, value ->
if (!value) prefs.updateExperimentalPreferences { preferAc3Surround = false }
prefs.updatePlaybackOverrides {
ac3Supported = value
}
prefs.updatePlaybackOverrides { ac3Supported = value }
},
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
val DownMixStereo =
AppSwitchPreference<AppPreferences>(
title = R.string.downmix_stereo,
defaultValue = false,
getter = { it.playbackPreferences.overrides.downmixStereo },
setter = { prefs, value ->
if (value) prefs.updateExperimentalPreferences { preferAc3Surround = false }
prefs.updatePlaybackOverrides {
downmixStereo = value
}
prefs.updatePlaybackOverrides { downmixStereo = value }
},
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
@ -583,13 +572,6 @@ sealed interface AppPreference<Pref, T> {
destination = Destination.Settings(PreferenceScreenOption.ADVANCED),
)
val UserProfileSettings =
AppDestinationPreference<AppPreferences>(
title = R.string.override_user_profile_settings,
destination = Destination.UserAppPreferences,
summary = R.string.override_user_profile_settings_summary,
)
val SkipIntros =
AppChoicePreference<AppPreferences, SkipSegmentBehavior>(
title = R.string.skip_intro_behavior,
@ -662,20 +644,6 @@ sealed interface AppPreference<Pref, T> {
destination = Destination.Settings(PreferenceScreenOption.SKIP_SEGMENTS),
)
val DpadSeekModePref =
AppChoicePreference<AppPreferences, DpadSeekMode>(
title = R.string.d_pad_seek_mode_title,
defaultValue = DpadSeekMode.SEEKBAR_MINIMAL,
getter = { it.playbackPreferences.dpadSeekMode },
setter = { prefs, value ->
prefs.updatePlaybackPreferences { dpadSeekMode = value }
},
displayValues = R.array.dpad_seek_mode_options,
indexToValue = { DpadSeekMode.forNumber(it) },
valueToIndex = { if (it != DpadSeekMode.UNRECOGNIZED) it.number else 0 },
subtitles = R.array.dpad_seek_mode_summaries,
)
val GlobalContentScale =
AppChoicePreference<AppPreferences, PrefContentScale>(
title = R.string.global_content_scale,
@ -1138,7 +1106,6 @@ val basicPreferences =
AppPreference.CustomizeHome,
AppPreference.UserPinnedNavDrawerItems,
AppPreference.UserInterfaceLanguage,
AppPreference.UserProfileSettings,
),
),
PreferenceGroup(
@ -1241,7 +1208,6 @@ val advancedPreferences =
AppPreference.CinemaMode,
AppPreference.GlobalContentScale,
AppPreference.SkipSegments,
AppPreference.DpadSeekModePref,
AppPreference.MaxBitrate,
AppPreference.RefreshRateSwitching,
AppPreference.ResolutionSwitching,
@ -1300,16 +1266,8 @@ val advancedPreferences =
AppPreference.DebugLogging,
AppPreference.ImageDiskCacheSize,
AppPreference.ClearImageCache,
ExperimentalPreference.Enable,
AppPreference.OssLicenseInfo,
),
conditionalPreferences =
listOf(
ConditionalPreferences(
condition = { it.experimentalPreferences.enabled },
preferences = listOf(ExperimentalPreference.ExperimentalSettings),
),
),
),
)
}
@ -1338,14 +1296,6 @@ val screensaverPreferences =
ScreensaverPreference.Start,
),
),
PreferenceGroup(
title = R.string.dim_screen,
preferences =
listOf(
ScreensaverPreference.DimToggle,
ScreensaverPreference.DimPercentage,
),
),
)
data class AppSwitchPreference<Pref>(
@ -1353,16 +1303,11 @@ data class AppSwitchPreference<Pref>(
override val defaultValue: Boolean,
override val getter: (prefs: Pref) -> Boolean,
override val setter: (prefs: Pref, value: Boolean) -> Pref,
val validator: (prefs: Pref, value: Boolean) -> PreferenceValidation = { _, _ -> PreferenceValidation.Valid },
val validator: (value: Boolean) -> PreferenceValidation = { PreferenceValidation.Valid },
@param:StringRes val summary: Int? = null,
@param:StringRes val summaryOn: Int? = null,
@param:StringRes val summaryOff: Int? = null,
) : AppPreference<Pref, Boolean> {
override fun validate(
prefs: Pref,
value: Boolean,
): PreferenceValidation = validator.invoke(prefs, value)
override fun summary(
context: Context,
value: Boolean?,

View file

@ -55,7 +55,6 @@ class AppPreferencesSerializer
AppPreference.RefreshRateSwitching.defaultValue
resolutionSwitching = AppPreference.ResolutionSwitching.defaultValue
cinemaMode = AppPreference.CinemaMode.defaultValue
dpadSeekMode = AppPreference.DpadSeekModePref.defaultValue
overrides =
PlaybackOverrides
@ -175,16 +174,6 @@ class AppPreferencesSerializer
showLyrics = true
showAlbumArt = true
}.build()
experimentalPreferences =
ExperimentalPreferences
.newBuilder()
.apply {
enabled = false
videoTunnelingEnabled = false
preferAc3Surround = ExperimentalPreference.PreferAc3ForSurround.defaultValue
disableAudioOffload = ExperimentalPreference.DisableAudioOffload.defaultValue
}.build()
}.build()
override suspend fun readFrom(input: InputStream): AppPreferences {
@ -263,11 +252,6 @@ inline fun AppPreferences.updateMusicPreferences(block: MusicPreferences.Builder
musicPreferences = musicPreferences.toBuilder().apply(block).build()
}
inline fun AppPreferences.updateExperimentalPreferences(block: ExperimentalPreferences.Builder.() -> Unit): AppPreferences =
update {
experimentalPreferences = experimentalPreferences.toBuilder().apply(block).build()
}
fun SubtitlePreferences.Builder.resetSubtitles() {
fontSize = SubtitleSettings.FontSize.defaultValue.toInt()
fontColor = SubtitleSettings.FontColor.defaultValue.toArgb()

View file

@ -1,96 +0,0 @@
package com.github.damontecres.wholphin.preferences
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
import com.github.damontecres.wholphin.ui.preferences.PreferenceValidation
object ExperimentalPreference {
val Enable =
AppSwitchPreference<AppPreferences>(
title = R.string.experimental_settings,
defaultValue = false,
getter = { it.experimentalPreferences.enabled },
setter = { prefs, value ->
prefs.updateExperimentalPreferences { enabled = value }
},
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
val ExperimentalSettings =
AppDestinationPreference<AppPreferences>(
title = R.string.experimental_settings,
destination = Destination.Settings(PreferenceScreenOption.EXPERIMENTAL),
)
val VideoTunneling =
AppSwitchPreference<AppPreferences>(
title = R.string.video_tunneling,
defaultValue = false,
getter = { it.experimentalPreferences.videoTunnelingEnabled },
setter = { prefs, value ->
prefs.updateExperimentalPreferences { videoTunnelingEnabled = value }
},
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
val PreferAc3ForSurround =
AppSwitchPreference<AppPreferences>(
title = R.string.prefer_ac3_for_surround,
defaultValue = false,
getter = { it.experimentalPreferences.preferAc3Surround },
setter = { prefs, value ->
prefs.updateExperimentalPreferences {
preferAc3Surround = value
}
},
summary = R.string.prefer_ac3_for_surround_summary,
validator = { prefs, value ->
prefs.playbackPreferences.overrides.let {
if (value && !it.ac3Supported) {
PreferenceValidation.Invalid("AC3 support is not enabled")
} else if (value && it.downmixStereo) {
PreferenceValidation.Invalid("Always downmixing to stereo")
} else {
PreferenceValidation.Valid
}
}
},
)
val DisableAudioOffload =
AppSwitchPreference<AppPreferences>(
title = R.string.disable_audio_offload,
defaultValue = false,
getter = { it.experimentalPreferences.disableAudioOffload },
setter = { prefs, value ->
prefs.updateExperimentalPreferences { disableAudioOffload = value }
},
summary = R.string.disable_audio_offload_summary,
)
}
val experimentalPreferences =
buildList {
add(
PreferenceGroup(
title = R.string.experimental_settings,
preferences =
listOf(
ExperimentalPreference.VideoTunneling,
ExperimentalPreference.PreferAc3ForSurround,
ExperimentalPreference.DisableAudioOffload,
),
),
)
}
/**
* Get a value from [ExperimentalPreference] or null if not enabled
*/
fun <T> ExperimentalPreferences.get(block: ExperimentalPreferences.() -> T): T? = if (enabled) block.invoke(this) else null
fun ExperimentalPreferences.enabled(block: ExperimentalPreferences.() -> Boolean): Boolean = enabled && block.invoke(this)

View file

@ -1,19 +1,5 @@
package com.github.damontecres.wholphin.preferences
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.playback.isDown
import com.github.damontecres.wholphin.ui.playback.isUp
import com.github.damontecres.wholphin.ui.tryRequestFocus
import kotlinx.coroutines.launch
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
@ -24,48 +10,3 @@ val PlaybackPreferences.skipBackOnResume: Duration?
} else {
null
}
/**
* Adds modifiers so that pressing Up on the first element scrolls to the bottom and vice versa
*/
@Composable
fun Modifier.lazyListWrapScrolling(
state: LazyListState,
focused: Boolean,
isFirst: Boolean,
isLast: Boolean,
firstFocusRequester: FocusRequester,
lastFocusRequester: FocusRequester,
): Modifier {
val scope = rememberCoroutineScope()
return this
.ifElse(
isFirst,
Modifier
.focusRequester(firstFocusRequester)
.onKeyEvent {
if (focused && isUp(it) && it.type == KeyEventType.KeyDown) {
scope.launch {
state.animateScrollToItem(state.layoutInfo.totalItemsCount - 1)
lastFocusRequester.tryRequestFocus()
}
return@onKeyEvent true
}
false
},
).ifElse(
isLast,
Modifier
.focusRequester(lastFocusRequester)
.onKeyEvent {
if (focused && isDown(it) && it.type == KeyEventType.KeyDown) {
scope.launch {
state.animateScrollToItem(0)
firstFocusRequester.tryRequestFocus()
}
return@onKeyEvent true
}
false
},
)
}

View file

@ -176,37 +176,6 @@ object ScreensaverPreference {
},
)
val DimToggle =
AppSwitchPreference<AppPreferences>(
title = R.string.dim_after_delay,
defaultValue = AppPreference.ShowClock.defaultValue,
getter = { it.interfacePreferences.screensaverPreference.dimEnabled },
setter = { prefs, value ->
prefs.updateScreensaverPreferences { dimEnabled = value }
},
summaryOn = R.string.yes,
summaryOff = R.string.no,
)
val DimPercentage =
AppSliderPreference<AppPreferences>(
title = R.string.dim_percentage,
defaultValue = 75,
min = 0,
max = 95,
interval = 5,
getter = {
it.interfacePreferences.screensaverPreference.dimPercent
.toLong()
},
setter = { prefs, value ->
prefs.updateScreensaverPreferences { dimPercent = value.toInt() }
},
summarizer = { value ->
value?.let { "$value%" }
},
)
val Start =
AppClickablePreference<AppPreferences>(
title = R.string.start_screensaver,

View file

@ -1,6 +1,5 @@
package com.github.damontecres.wholphin.preferences
import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
import org.jellyfin.sdk.model.api.UserConfiguration
@ -9,7 +8,6 @@ import org.jellyfin.sdk.model.api.UserConfiguration
*/
data class UserPreferences(
val appPreferences: AppPreferences,
val userPreferences: JellyfinUserPreferences?,
)
val DefaultUserConfiguration =

View file

@ -1,70 +0,0 @@
package com.github.damontecres.wholphin.preferences
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences
import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup
/**
* Represents settings from the user's profile on the server that may be overridden within the app
*/
object UserProfileSettings {
/**
* Special value that means the preferred language should be taken from the user's profile on the server
*/
const val USE_USER_PROFILE = ""
/**
* Special value that means the user has no preferred language
*/
const val PREFER_ANY_LANGUAGE = "_any-language"
val PreferredAudioLang =
AppClickablePreference<JellyfinUserPreferences>(
title = R.string.preferred_audio_language,
getter = { },
setter = { prefs, _ -> prefs },
)
val PreferredSubtitleLang =
AppClickablePreference<JellyfinUserPreferences>(
title = R.string.preferred_subtitle_language,
summary = null,
getter = { },
setter = { prefs, _ -> prefs },
)
val SubtitleModePref =
AppChoicePreference<JellyfinUserPreferences, SubtitleModePreference>(
title = R.string.subtitle_mode,
defaultValue = SubtitleModePreference.USE_USER_PROFILE,
getter = { it.subtitleMode },
setter = { prefs, value ->
prefs.copy(subtitleMode = value)
},
displayValues = R.array.subtitle_mode_options,
indexToValue = { SubtitleModePreference.entries[it] },
valueToIndex = { it.ordinal },
)
val Preferences =
listOf(
PreferenceGroup(
title = R.string.profile_specific_settings_from_server,
preferences =
listOf(
PreferredAudioLang,
PreferredSubtitleLang,
SubtitleModePref,
),
),
)
}
enum class SubtitleModePreference {
USE_USER_PROFILE,
DEFAULT,
SMART,
ONLY_FORCED,
ALWAYS,
NONE,
}

View file

@ -6,18 +6,12 @@ import android.os.Build
import androidx.core.content.edit
import androidx.datastore.core.DataStore
import androidx.preference.PreferenceManager
import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.RememberedTabDao
import com.github.damontecres.wholphin.data.SeerrServerDao
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.RememberedTab
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.ExperimentalPreference
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.updateExperimentalPreferences
import com.github.damontecres.wholphin.preferences.updateHomePagePreferences
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences
@ -31,16 +25,12 @@ import com.github.damontecres.wholphin.preferences.updateSearchPreferences
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.ui.preferences.subtitle.shouldEnableSeparateHdrToggle
import com.github.damontecres.wholphin.ui.setup.seerr.migrateSeerrUrl
import com.github.damontecres.wholphin.util.Version
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.first
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.io.File
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@ -54,8 +44,6 @@ class AppUpgradeHandler
@param:ApplicationContext private val context: Context,
private val appPreferences: DataStore<AppPreferences>,
private val seerrServerDao: SeerrServerDao,
private val serverDao: JellyfinServerDao,
private val rememberedTabDao: RememberedTabDao,
) {
val pkgInfo: PackageInfo get() = context.packageManager.getPackageInfo(context.packageName, 0)
val currentVersion: Version get() = Version.fromString(pkgInfo.versionName!!)
@ -362,85 +350,5 @@ class AppUpgradeHandler
}
}
}
if (previous.isEqualOrBefore(Version.fromString("1.0.1-2-g0"))) {
Timber.i("Starting tab migration")
val users = mutableMapOf<UUID, JellyfinUser?>()
val tabs =
appPreferences.data.first().interfacePreferences.rememberedTabsMap.mapNotNull { (key, tabIndex) ->
// "serverId_userId_itemId", itemId can have underscores
try {
val firstUnderScore = key.indexOf("_").takeIf { it >= 1 }
if (firstUnderScore != null) {
val secondUnderscore =
key
.indexOf("_", startIndex = firstUnderScore + 1)
.takeIf { it >= 1 }
if (secondUnderscore != null) {
val serverId = key.substring(0, firstUnderScore).toUUIDOrNull()
val userId =
key
.substring(firstUnderScore + 1, secondUnderscore)
.toUUIDOrNull()
val itemId = key.substring(secondUnderscore + 1, key.length)
if (userId != null && serverId != null) {
return@mapNotNull users
.getOrPut(userId) {
serverDao.getUser(serverId, userId)
}?.let {
RememberedTab(it.rowId, itemId, tabIndex)
}
}
}
}
} catch (ex: Exception) {
Timber.w(ex, "Error parsing '%s'", key)
}
null
}
Timber.v("Migrating %s tabs", tabs.size)
try {
if (tabs.isNotEmpty()) {
rememberedTabDao.saveAll(tabs)
}
appPreferences.updateData {
it.updateInterfacePreferences {
clearRememberedTabs()
}
}
} catch (ex: Exception) {
Timber.e(ex, "Error saving migrated tabs")
}
}
if (previous.isEqualOrBefore(Version.fromString("1.0.2-8-g0"))) {
appPreferences.updateData {
it.updateSubtitlePreferences {
useSeparateHdr = it.interfacePreferences.shouldEnableSeparateHdrToggle()
}
}
}
if (previous.isEqualOrBefore(Version.fromString("1.0.6-0-g0"))) {
appPreferences.updateData {
it.updateScreensaverPreferences {
dimPercent = ScreensaverPreference.DimPercentage.defaultValue.toInt()
}
}
}
if (previous.isEqualOrBefore(Version.fromString("1.0.6-7-g0"))) {
// preferAc3Surround was mistaken enabled by default, reset it only if the user hasn't enabled experimental settings
appPreferences.updateData {
if (!it.experimentalPreferences.enabled) {
it.updateExperimentalPreferences {
preferAc3Surround =
ExperimentalPreference.PreferAc3ForSurround.defaultValue
}
} else {
it
}
}
}
}
}

View file

@ -18,8 +18,8 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.BackdropStyle
import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@ -55,7 +55,7 @@ class BackdropService
* Update the backdrop to use the specified item
*/
suspend fun submit(item: BaseItem) =
withContext(WholphinDispatchers.IO) {
withContext(Dispatchers.IO) {
val imageUrl =
if (item.type == BaseItemKind.GENRE) {
item.imageUrlOverride
@ -76,7 +76,7 @@ class BackdropService
suspend fun submit(
itemId: String,
imageUrl: String?,
) = withContext(WholphinDispatchers.IO) {
) = withContext(Dispatchers.IO) {
if (backdropFlow.firstOrNull()?.imageUrl != imageUrl) {
_backdropFlow.update {
it.copy(
@ -132,7 +132,7 @@ class BackdropService
}
suspend fun extractColorsFromBackdrop(imageUrl: String?): ExtractedColors =
withContext(WholphinDispatchers.IO) {
withContext(Dispatchers.IO) {
if (imageUrl.isNullOrBlank()) {
return@withContext ExtractedColors.DEFAULT
}

View file

@ -9,12 +9,12 @@ import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
import com.github.damontecres.wholphin.ui.collectLatestIn
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.google.common.cache.CacheBuilder
import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
@ -56,7 +56,7 @@ class DatePlayedService
adjacentTo = item.id,
limit = 1,
)
return scope.async(WholphinDispatchers.IO) {
return scope.async(Dispatchers.IO) {
val premiereDate = item.data.premiereDate
try {
val result =
@ -92,7 +92,7 @@ class DatePlayedService
* the previous episode's last played timestamp, and the episode's premiere date
*/
suspend fun getLastPlayed(item: BaseItem): LocalDateTime? =
withContext(WholphinDispatchers.IO) {
withContext(Dispatchers.IO) {
val seriesId = item.data.seriesId
return@withContext if (seriesId != null) {
datePlayedCache

View file

@ -1,15 +1,12 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AssPlaybackMode
import com.github.damontecres.wholphin.preferences.ExperimentalPreferences
import com.github.damontecres.wholphin.preferences.PlaybackOverrides
import com.github.damontecres.wholphin.preferences.enabled
import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.preferences.PlaybackPreferences
import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest
import com.github.damontecres.wholphin.util.profile.createDeviceProfile
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@ -37,17 +34,20 @@ class DeviceProfileService
private var deviceProfile: DeviceProfile? = null
suspend fun getOrCreateDeviceProfile(
appPrefs: AppPreferences,
prefs: PlaybackPreferences,
serverVersion: ServerVersion?,
): DeviceProfile =
withContext(WholphinDispatchers.Default) {
val prefs = appPrefs.playbackPreferences
withContext(Dispatchers.Default) {
mutex.withLock {
val newConfig =
DeviceProfileConfiguration(
maxBitrate = prefs.maxBitrate.toInt(),
overrides = prefs.overrides,
experimental = appPrefs.experimentalPreferences,
isAC3Enabled = prefs.overrides.ac3Supported,
downMixAudio = prefs.overrides.downmixStereo,
assPlaybackMode = prefs.overrides.assPlaybackMode,
pgsDirectPlay = prefs.overrides.directPlayPgs,
dolbyVisionELDirectPlay = prefs.overrides.directPlayDolbyVisionEL,
decodeAv1 = prefs.overrides.decodeAv1,
jellyfinTenEleven =
serverVersion != null && serverVersion >= ServerVersion(10, 11, 0),
)
@ -57,13 +57,12 @@ class DeviceProfileService
createDeviceProfile(
mediaTest = mediaCodecCapabilitiesTest,
maxBitrate = newConfig.maxBitrate,
isAC3Enabled = newConfig.overrides.ac3Supported,
downMixAudio = newConfig.overrides.downmixStereo,
assDirectPlay = newConfig.overrides.assPlaybackMode != AssPlaybackMode.ASS_TRANSCODE,
pgsDirectPlay = newConfig.overrides.directPlayPgs,
dolbyVisionELDirectPlay = newConfig.overrides.directPlayDolbyVisionEL,
isAC3Enabled = newConfig.isAC3Enabled,
downMixAudio = newConfig.downMixAudio,
assDirectPlay = newConfig.assPlaybackMode != AssPlaybackMode.ASS_TRANSCODE,
pgsDirectPlay = newConfig.pgsDirectPlay,
dolbyVisionELDirectPlay = newConfig.dolbyVisionELDirectPlay,
decodeAv1 = prefs.overrides.decodeAv1,
preferAc3ForSurround = appPrefs.experimentalPreferences.enabled { preferAc3Surround },
jellyfinTenEleven = newConfig.jellyfinTenEleven,
)
}
@ -77,7 +76,11 @@ class DeviceProfileService
*/
data class DeviceProfileConfiguration(
val maxBitrate: Int,
val overrides: PlaybackOverrides,
val experimental: ExperimentalPreferences,
val isAC3Enabled: Boolean,
val downMixAudio: Boolean,
val assPlaybackMode: AssPlaybackMode,
val pgsDirectPlay: Boolean,
val dolbyVisionELDirectPlay: Boolean,
val decodeAv1: Boolean,
val jellyfinTenEleven: Boolean,
)

View file

@ -5,8 +5,6 @@ import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ExtrasItem
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.pluralRes
import com.github.damontecres.wholphin.data.stringRes
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import dagger.hilt.android.qualifiers.ApplicationContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
@ -50,10 +48,9 @@ class ExtrasService
if (items.size == 1) {
val item = items.first()
val title =
item.title.takeIf { it.isNotNullOrBlank() }
?: context.resources.getQuantityString(type.pluralRes, 1)
item.title ?: context.resources.getQuantityString(type.pluralRes, 1)
val subtitle =
if (item.title.isNotNullOrBlank()) {
if (item.title == null) {
context.resources.getQuantityString(type.pluralRes, 1)
} else {
null
@ -67,14 +64,14 @@ class ExtrasService
imageUrl = imageUrlService.getItemImageUrl(item, ImageType.PRIMARY),
)
} else if (items.size > 1) {
val title = context.resources.getString(type.stringRes)
val title =
context.resources.getQuantityString(type.pluralRes, items.size)
val subtitle =
context.resources.getQuantityString(
R.plurals.items,
items.size,
items.size,
)
val itemForImage = items.random()
ExtrasItem.Group(
parentId = itemId,
type = type,
@ -84,10 +81,9 @@ class ExtrasService
isPlayed = items.all { it.played },
imageUrl =
imageUrlService.getItemImageUrl(
itemForImage,
items.random(),
ImageType.PRIMARY,
),
aspectRatio = itemForImage.aspectRatio,
)
} else {
null

View file

@ -1,270 +0,0 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import com.github.damontecres.wholphin.api.seerr.model.TvShowStatus
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.filter.CommunityRatingFilter
import com.github.damontecres.wholphin.data.filter.DecadeFilter
import com.github.damontecres.wholphin.data.filter.DiscoverFilterBy
import com.github.damontecres.wholphin.data.filter.DiscoverMovieContentRatingFilter
import com.github.damontecres.wholphin.data.filter.DiscoverMovieGenreFilter
import com.github.damontecres.wholphin.data.filter.DiscoverMovieStudiosFilter
import com.github.damontecres.wholphin.data.filter.DiscoverRatingFilter
import com.github.damontecres.wholphin.data.filter.DiscoverTvContentRatingFilter
import com.github.damontecres.wholphin.data.filter.DiscoverTvGenreFilter
import com.github.damontecres.wholphin.data.filter.DiscoverTvStatusFilter
import com.github.damontecres.wholphin.data.filter.DiscoverTvStudiosFilter
import com.github.damontecres.wholphin.data.filter.FavoriteFilter
import com.github.damontecres.wholphin.data.filter.FilterBy
import com.github.damontecres.wholphin.data.filter.FilterValueOption
import com.github.damontecres.wholphin.data.filter.FilterVideoType
import com.github.damontecres.wholphin.data.filter.GenreFilter
import com.github.damontecres.wholphin.data.filter.OfficialRatingFilter
import com.github.damontecres.wholphin.data.filter.PlayedFilter
import com.github.damontecres.wholphin.data.filter.StudioFilter
import com.github.damontecres.wholphin.data.filter.VideoTypeFilter
import com.github.damontecres.wholphin.data.filter.YearFilter
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
import com.github.damontecres.wholphin.ui.showToast
import com.mayakapps.kache.InMemoryKache
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.firstOrNull
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.filterApi
import org.jellyfin.sdk.api.client.extensions.genresApi
import org.jellyfin.sdk.api.client.extensions.localizationApi
import org.jellyfin.sdk.api.client.extensions.studiosApi
import org.jellyfin.sdk.api.client.extensions.yearsApi
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import timber.log.Timber
import java.util.TreeSet
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.hours
/**
* Get the possible values for filters in a library
*/
@Singleton
class FilterOptionCache
@Inject
constructor(
@param:ApplicationContext private val context: Context,
@param:IoCoroutineScope private val ioCoroutineScope: CoroutineScope,
private val api: ApiClient,
private val serverRepository: ServerRepository,
private val seerrService: SeerrService,
) {
private val cache =
InMemoryKache<FilterOptionCacheKey, List<FilterValueOption>>(16) {
creationScope = ioCoroutineScope
expireAfterWriteDuration = 1.hours
}
/**
* Gets the possible values for a filter
*
* For example, the possible genres in the parent ID
*/
suspend fun getFilterOptionValues(
parentId: UUID?,
filterOption: FilterBy<*, *>,
): List<FilterValueOption> {
val cacheKey = FilterOptionCacheKey(serverRepository.currentUser?.id, parentId, filterOption)
return try {
cache
.getOrPut(cacheKey) { (userId, parentId, filterOption) ->
getFilterOptionValues(userId, parentId, filterOption)
}.orEmpty()
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Error fetching options for %s", filterOption)
showToast(context, "Error occurred: ${ex.localizedMessage}")
emptyList()
}
}
private suspend fun getFilterOptionValues(
userId: UUID?,
parentId: UUID?,
filterOption: FilterBy<*, *>,
) = when (filterOption) {
GenreFilter -> {
api.genresApi
.getGenres(
parentId = parentId,
userId = userId,
).content.items
.map { FilterValueOption(it.name ?: "", it.id) }
}
StudioFilter -> {
api.studiosApi
.getStudios(
parentId = parentId,
userId = userId,
includeItemTypes = listOf(BaseItemKind.SERIES),
).content.items
.map { FilterValueOption(it.name ?: "", it.id) }
}
FavoriteFilter,
PlayedFilter,
-> {
listOf(
FilterValueOption("True", null),
FilterValueOption("False", null),
)
}
OfficialRatingFilter -> {
val ratings =
api.localizationApi
.getParentalRatings()
.content
.associate {
it.name to it.value
}
api.filterApi
.getQueryFiltersLegacy(
parentId = parentId,
userId = userId,
).content.officialRatings
?.map { r ->
val value = ratings[r] ?: 0
FilterValueOption(r, value)
}?.sortedBy { it.value as Int }
.orEmpty()
}
VideoTypeFilter -> {
FilterVideoType.entries.map {
FilterValueOption(it.readable, it)
}
}
YearFilter -> {
api.yearsApi
.getYears(
parentId = parentId,
userId = userId,
sortBy = listOf(ItemSortBy.SORT_NAME),
sortOrder = listOf(SortOrder.ASCENDING),
).content.items
.mapNotNull {
it.name?.toIntOrNull()?.let { FilterValueOption(it.toString(), it) }
}
}
DecadeFilter -> {
val items = TreeSet<Int>()
api.yearsApi
.getYears(
parentId = parentId,
userId = userId,
sortBy = listOf(ItemSortBy.SORT_NAME),
sortOrder = listOf(SortOrder.ASCENDING),
).content.items
.mapNotNullTo(items) {
it.name
?.toIntOrNull()
?.div(10)
?.times(10)
}
items.toList().sorted().map { FilterValueOption("$it's", it) }
}
CommunityRatingFilter -> {
(1..10).map {
FilterValueOption("$it", it)
}
}
is DiscoverFilterBy -> {
getDiscoverFilterOptionValues(filterOption)
}
}
private suspend fun getDiscoverFilterOptionValues(filterOption: DiscoverFilterBy<*>): List<FilterValueOption> {
if (seerrService.active.firstOrNull() != true) {
return emptyList()
}
return when (filterOption) {
DiscoverMovieGenreFilter -> {
seerrService.api.searchApi
.discoverGenresliderMovieGet()
.filter { it.name != null && it.id != null }
.map {
FilterValueOption(it.name!!, it.id!!)
}
}
DiscoverTvGenreFilter -> {
seerrService.api.searchApi
.discoverGenresliderTvGet()
.filter { it.name != null && it.id != null }
.map {
FilterValueOption(it.name!!, it.id!!)
}
}
DiscoverMovieStudiosFilter -> {
// TODO region
seerrService.api.otherApi
.watchprovidersMoviesGet("US")
.filter { it.name != null && it.id != null }
// TODO not what this is for
// .sortedBy { it.displayPriority }
.map {
// TODO logo?
FilterValueOption(it.name!!, it.id!!)
}
}
DiscoverTvStudiosFilter -> {
// TODO region
seerrService.api.otherApi
.watchprovidersTvGet("US")
.filter { it.name != null && it.id != null }
// .sortedBy { it.displayPriority }
.map {
FilterValueOption(it.name!!, it.id!!)
}
}
DiscoverTvStatusFilter -> {
TvShowStatus.entries.map {
FilterValueOption(it.value, it)
}
}
DiscoverMovieContentRatingFilter -> {
listOf("NR", "G", "PG", "PG-13", "R", "NC-17")
.map { FilterValueOption(it, it) }
}
DiscoverTvContentRatingFilter -> {
listOf("NR", "TV-Y", "TV-Y7", "TV-G", "TV-PG", "TV-14", "TV-MA")
.map { FilterValueOption(it, it) }
}
DiscoverRatingFilter -> {
(1..10).map {
FilterValueOption("$it", it)
}
}
}
}
private data class FilterOptionCacheKey(
val userId: UUID?,
val parentId: UUID?,
val filterOption: FilterBy<*, *>,
)
}

View file

@ -7,19 +7,16 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.HomePageSettings
import com.github.damontecres.wholphin.data.model.HomeRowConfig
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION
import com.github.damontecres.wholphin.data.model.createGenreDestination
import com.github.damontecres.wholphin.data.model.createStudioDestination
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
import com.github.damontecres.wholphin.preferences.HomePagePreferences
import com.github.damontecres.wholphin.ui.AspectRatio
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.HomeItemFields
import com.github.damontecres.wholphin.ui.ProgramItemFields
import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.components.getGenreImageMap
import com.github.damontecres.wholphin.ui.formatTypeName
import com.github.damontecres.wholphin.ui.main.settings.Library
import com.github.damontecres.wholphin.ui.main.settings.favoriteOptions
import com.github.damontecres.wholphin.ui.playback.getTypeFor
import com.github.damontecres.wholphin.ui.toBaseItems
import com.github.damontecres.wholphin.ui.toServerString
@ -29,16 +26,15 @@ import com.github.damontecres.wholphin.ui.util.ResStringProvider
import com.github.damontecres.wholphin.ui.util.StringProvider
import com.github.damontecres.wholphin.ui.util.StringStringProvider
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.GetArtistsHandler
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetLiveTvChannelsRequestHandler
import com.github.damontecres.wholphin.util.GetPersonsHandler
import com.github.damontecres.wholphin.util.GetProgramsDtoHandler
import com.github.damontecres.wholphin.util.GetRecordingsRequestHandler
import com.github.damontecres.wholphin.util.GetStudiosRequestHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success
import com.github.damontecres.wholphin.util.supportedHomeCollectionTypes
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
@ -57,22 +53,20 @@ import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.DateTime
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.GetProgramsDto
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.UserDto
import org.jellyfin.sdk.model.api.request.GetArtistsRequest
import org.jellyfin.sdk.model.api.request.GetGenresRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
import org.jellyfin.sdk.model.api.request.GetRecommendedProgramsRequest
import org.jellyfin.sdk.model.api.request.GetRecordingsRequest
import org.jellyfin.sdk.model.api.request.GetStudiosRequest
import timber.log.Timber
@ -245,7 +239,7 @@ class HomeSettingsService
settings.rows.mapIndexed { index, config ->
resolve(index, config)
}
HomePageResolvedSettings(userId, resolvedRows)
HomePageResolvedSettings(resolvedRows)
} else {
createDefault(userId)
}
@ -256,15 +250,12 @@ class HomeSettingsService
/**
* Resolve the settings and set them to be the current settings
*/
suspend fun updateCurrent(
userId: UUID,
settings: HomePageSettings,
) {
suspend fun updateCurrent(settings: HomePageSettings) {
val resolvedRows =
settings.rows.mapIndexed { index, config ->
resolve(index, config)
}
val resolvedSettings = HomePageResolvedSettings(userId, resolvedRows)
val resolvedSettings = HomePageResolvedSettings(resolvedRows)
currentSettings.update { resolvedSettings }
}
@ -284,25 +275,20 @@ class HomeSettingsService
val includedIds =
libraries
.mapIndexed { index, library ->
val parentId = library.itemId
val title = getRecentlyAddedTitle(library.name)
if (library.collectionType == CollectionType.LIVETV) {
.mapIndexed { index, it ->
val parentId = it.itemId
val title = getRecentlyAddedTitle(it)
if (it.collectionType == CollectionType.LIVETV) {
HomeRowConfigDisplay(
id = index,
title = ResStringProvider(R.string.watch_live),
title = ResStringProvider(R.string.live_tv),
config = HomeRowConfig.TvPrograms(),
)
} else {
val viewOptions = viewOptionsForCollectionType(library.collectionType)
HomeRowConfigDisplay(
id = index,
title = title,
config =
HomeRowConfig.RecentlyAdded(
parentId = parentId,
viewOptions = viewOptions,
),
config = HomeRowConfig.RecentlyAdded(parentId),
)
}
}
@ -315,7 +301,7 @@ class HomeSettingsService
),
)
val rowConfig = continueWatchingRow + includedIds
return HomePageResolvedSettings(userId, rowConfig)
return HomePageResolvedSettings(rowConfig)
}
/**
@ -332,10 +318,12 @@ class HomeSettingsService
val userDto by api.userApi.getUserById(userId)
val config = userDto.configuration ?: DefaultUserConfiguration
val libraries =
navDrawerService
.getAllUserLibraries(userId, userDto.tvAccess)
.filterNot {
it.itemId in config.latestItemsExcludes
api.userViewsApi
.getUserViews(userId = userId)
.content.items
.filter {
it.collectionType in supportedHomeCollectionTypes &&
it.id !in config.latestItemsExcludes
}
return if (customPrefs.isNotEmpty()) {
@ -379,7 +367,7 @@ class HomeSettingsService
if (userDto.tvAccess) {
HomeRowConfigDisplay(
id = id++,
title = ResStringProvider(R.string.watch_live),
title = ResStringProvider(R.string.live_tv),
config = HomeRowConfig.TvPrograms(),
)
} else {
@ -408,21 +396,15 @@ class HomeSettingsService
}
}
if (sectionType == HomeSectionType.LATEST_MEDIA) {
libraries.map { library ->
val viewOptions =
viewOptionsForCollectionType(library.collectionType)
libraries.map {
HomeRowConfigDisplay(
id = id++,
title =
ResArgStringProvider(
R.string.recently_added_in,
library.name ?: "",
),
config =
HomeRowConfig.RecentlyAdded(
parentId = library.itemId,
viewOptions = viewOptions,
it.name ?: "",
),
config = HomeRowConfig.RecentlyAdded(it.id),
)
}
} else if (config != null) {
@ -431,7 +413,7 @@ class HomeSettingsService
null
}
}.flatten()
HomePageResolvedSettings(userId, rowConfigs)
HomePageResolvedSettings(rowConfigs)
} else {
null
}
@ -521,8 +503,8 @@ class HomeSettingsService
is HomeRowConfig.Favorite -> {
val name =
ResProviderStringProvider(
R.string.favorite_items_title,
ResStringProvider(formatTypeName(config.kind)),
R.string.favorite_items,
ResStringProvider(favoriteOptions[config.kind]!!),
)
HomeRowConfigDisplay(id, name, config)
}
@ -538,7 +520,7 @@ class HomeSettingsService
is HomeRowConfig.TvPrograms -> {
HomeRowConfigDisplay(
id = id,
title = ResStringProvider(R.string.watch_live),
title = ResStringProvider(R.string.live_tv),
config,
)
}
@ -568,10 +550,8 @@ class HomeSettingsService
): StringProvider =
try {
api.userLibraryApi
.getItem(
userId = serverRepository.currentUser?.id,
itemId = itemId,
).content.name
.getItem(itemId = itemId)
.content.name
?.let {
if (stringRes == null) {
StringStringProvider(it)
@ -612,7 +592,6 @@ class HomeSettingsService
items = resume,
viewOptions = row.viewOptions,
rowType = row,
showViewMore = resume.size >= limit,
)
}
@ -632,7 +611,6 @@ class HomeSettingsService
items = nextUp,
viewOptions = row.viewOptions,
rowType = row,
showViewMore = nextUp.size >= limit,
)
}
@ -653,14 +631,17 @@ class HomeSettingsService
prefs.maxDaysNextUp,
row.viewOptions.useSeries,
)
val combined = latestNextUpService.buildCombined(resume, nextUp)
Success(
title = ResStringProvider(R.string.continue_watching),
items = combined.take(limit),
items =
latestNextUpService
.buildCombined(
resume,
nextUp,
).take(limit),
viewOptions = row.viewOptions,
rowType = row,
showViewMore = combined.size >= limit,
)
}
@ -712,9 +693,6 @@ class HomeSettingsService
listOf(it)
}
},
collectionType =
library?.collectionType
?: CollectionType.UNKNOWN,
),
)
}
@ -724,7 +702,6 @@ class HomeSettingsService
genres,
viewOptions = row.viewOptions,
rowType = row,
showViewMore = genres.size >= limit,
)
}
@ -776,16 +753,17 @@ class HomeSettingsService
title,
studios,
viewOptions = row.viewOptions,
showViewMore = studios.size >= limit,
)
}
is HomeRowConfig.RecentlyAdded -> {
val library = libraries.firstOrNull { it.itemId == row.parentId }
val title = getRecentlyAddedTitle(library?.name)
val library =
libraries
.firstOrNull { it.itemId == row.parentId }
val title = getRecentlyAddedTitle(library)
val request =
GetLatestMediaRequest(
fields = library.itemFields,
fields = SlimItemFields,
imageTypeLimit = 1,
parentId = row.parentId,
groupItems = true,
@ -803,16 +781,18 @@ class HomeSettingsService
it,
row.viewOptions,
rowType = row,
showViewMore = it.size >= limit,
)
}
latest
}
is HomeRowConfig.RecentlyReleased -> {
val library = libraries.firstOrNull { it.itemId == row.parentId }
val name =
libraries
.firstOrNull { it.itemId == row.parentId }
?.name
val title =
library?.name?.let {
name?.let {
ResArgStringProvider(R.string.recently_released_in, it)
} ?: ResStringProvider(R.string.recently_released)
val request =
@ -831,7 +811,7 @@ class HomeSettingsService
SortOrder.ASCENDING,
SortOrder.DESCENDING,
),
fields = library.itemFields,
fields = DefaultItemFields,
recursive = true,
maxPremiereDate = LocalDateTime.now(),
isUnaired = false,
@ -855,13 +835,11 @@ class HomeSettingsService
it,
row.viewOptions,
rowType = row,
showViewMore = it.size >= limit,
)
}
}
is HomeRowConfig.ByParent -> {
val library = libraries.firstOrNull { it.itemId == row.parentId }
val request =
GetItemsRequest(
userId = userDto.id,
@ -870,44 +848,27 @@ class HomeSettingsService
sortBy =
row.sort?.let {
buildList {
if (it.sort == ItemSortBy.RANDOM) {
add(ItemSortBy.SORT_NAME)
add(ItemSortBy.RANDOM)
} else {
add(it.sort)
if (it.sort != ItemSortBy.SORT_NAME) {
add(ItemSortBy.SORT_NAME)
}
}
}
},
sortOrder =
row.sort?.let {
buildList {
if (it.sort == ItemSortBy.RANDOM) {
add(SortOrder.ASCENDING)
add(it.direction)
} else {
add(it.direction)
if (it.sort != ItemSortBy.SORT_NAME) {
add(SortOrder.ASCENDING)
}
}
}
},
limit = limit,
fields = library.itemFields,
fields = DefaultItemFields,
)
// Not using getItemName because we want to throw the 404
val title =
api.userLibraryApi
.getItem(
userId = serverRepository.currentUser?.id,
itemId = row.parentId,
).content.name
?.let { StringStringProvider(it) }
?: ResStringProvider(R.string.collection)
getItemName(null, row.parentId, ResStringProvider(R.string.collection))
if (usePaging) {
ApiRequestPager(
api,
@ -927,7 +888,6 @@ class HomeSettingsService
it,
row.viewOptions,
rowType = row,
showViewMore = it.size >= limit,
)
}
}
@ -965,7 +925,6 @@ class HomeSettingsService
it,
row.viewOptions,
rowType = row,
showViewMore = it.size >= limit,
)
}
}
@ -973,61 +932,37 @@ class HomeSettingsService
is HomeRowConfig.Favorite -> {
val title =
ResProviderStringProvider(
R.string.favorite_items_title,
ResStringProvider(formatTypeName(row.kind)),
R.string.favorite_items,
ResStringProvider(favoriteOptions[row.kind]!!),
)
val resultList =
when (row.kind) {
BaseItemKind.PERSON -> {
if (row.kind == BaseItemKind.PERSON) {
val request =
GetPersonsRequest(
userId = userDto.id,
limit = limit,
fields = HomeItemFields,
fields = DefaultItemFields,
isFavorite = true,
enableImages = true,
enableImageTypes = listOf(ImageType.PRIMARY),
)
GetPersonsHandler.execute(api, request).toBaseItems()
}
BaseItemKind.MUSIC_ARTIST -> {
val request =
GetArtistsRequest(
userId = userDto.id,
limit = limit,
fields = HomeItemFields,
isFavorite = true,
)
if (usePaging) {
ApiRequestPager(
api,
request,
GetArtistsHandler,
scope,
useSeriesForPrimary = row.viewOptions.useSeries,
).init()
} else {
GetArtistsHandler
GetPersonsHandler
.execute(api, request)
.toBaseItems(row.viewOptions.useSeries)
.content.items
.map { BaseItem(it, true) }
.let {
Success(
title,
it,
row.viewOptions,
)
}
}
else -> {
val fields =
if (row.kind == BaseItemKind.BOX_SET) {
HomeItemFieldsBoxSets
} else {
HomeItemFields
}
val request =
GetItemsRequest(
userId = userDto.id,
recursive = true,
limit = limit,
fields = fields,
fields = DefaultItemFields,
includeItemTypes = listOf(row.kind),
isFavorite = true,
)
@ -1042,27 +977,25 @@ class HomeSettingsService
} else {
GetItemsRequestHandler
.execute(api, request)
.toBaseItems(row.viewOptions.useSeries)
}
}
}
resultList.let {
.content.items
.map { BaseItem(it, row.viewOptions.useSeries) }
}.let {
Success(
title,
it,
row.viewOptions,
rowType = row,
showViewMore = it.size >= limit,
)
}
}
}
is HomeRowConfig.Recordings -> {
val request =
GetRecordingsRequest(
userId = userDto.id,
isInProgress = true,
fields = HomeItemFields,
fields = DefaultItemFields,
limit = limit,
enableImages = true,
enableUserData = true,
@ -1087,44 +1020,32 @@ class HomeSettingsService
it,
row.viewOptions,
rowType = row,
showViewMore = it.size >= limit,
)
}
}
is HomeRowConfig.TvPrograms -> {
val request =
GetProgramsDto(
GetRecommendedProgramsRequest(
userId = userDto.id,
fields = ProgramItemFields,
fields = DefaultItemFields,
limit = limit,
enableUserData = true,
enableImages = true,
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.LOGO),
imageTypeLimit = 1,
isAiring = true,
minEndDate = DateTime.now().plusMinutes(1),
)
if (usePaging) {
ApiRequestPager(
api,
request,
GetProgramsDtoHandler,
scope,
useSeriesForPrimary = row.viewOptions.useSeries,
).init()
} else {
// paging not supported
api.liveTvApi
.getPrograms(request)
.getRecommendedPrograms(request)
.content.items
.map { BaseItem(it, row.viewOptions.useSeries) }
}.let {
.let {
Success(
ResStringProvider(R.string.watch_live),
ResStringProvider(R.string.live_tv),
it,
row.viewOptions,
rowType = row,
showViewMore = it.size >= limit,
)
}
}
@ -1133,7 +1054,7 @@ class HomeSettingsService
val request =
GetLiveTvChannelsRequest(
userId = userDto.id,
fields = HomeItemFields,
fields = DefaultItemFields,
limit = limit,
enableImages = true,
)
@ -1155,7 +1076,6 @@ class HomeSettingsService
it,
row.viewOptions,
rowType = row,
showViewMore = it.size >= limit,
)
}
}
@ -1188,7 +1108,6 @@ class HomeSettingsService
suggestions.items,
row.viewOptions,
rowType = row,
showViewMore = suggestions.items.size >= limit,
)
}
@ -1227,11 +1146,10 @@ data class HomeRowConfigDisplay(
* @see HomePageSettings
*/
data class HomePageResolvedSettings(
val userId: UUID,
val rows: List<HomeRowConfigDisplay>,
) {
companion object {
val EMPTY = HomePageResolvedSettings(UUID.randomUUID(), emptyList())
val EMPTY = HomePageResolvedSettings(listOf())
}
}
@ -1261,56 +1179,10 @@ class UnsupportedHomeSettingsVersionException(
val maxSupportedVersion: Int = SUPPORTED_HOME_PAGE_SETTINGS_VERSION,
) : Exception("Unsupported version $unsupportedVersion, max supported is $maxSupportedVersion")
fun getRecentlyAddedTitle(name: String?): StringProvider =
name?.let { ResArgStringProvider(R.string.recently_added_in, it) }
?: ResStringProvider(R.string.recently_added)
private val Library?.itemFields: List<ItemFields>
get() =
if (this?.type == BaseItemKind.COLLECTION_FOLDER && this.collectionType == CollectionType.BOXSETS) {
// Get child count to show in the header
HomeItemFieldsBoxSets
fun getRecentlyAddedTitle(library: Library?): StringProvider =
if (library?.isRecordingFolder == true) {
ResStringProvider(R.string.recently_recorded)
} else {
HomeItemFields
}
private val HomeItemFieldsBoxSets get() = HomeItemFields + listOf(ItemFields.CHILD_COUNT)
fun viewOptionsForCollectionType(collectionType: CollectionType?): HomeRowViewOptions =
when (collectionType) {
CollectionType.MUSIC,
-> {
HomeRowViewOptions(
heightDp = Cards.HEIGHT_EPISODE,
aspectRatio = AspectRatio.SQUARE,
)
}
CollectionType.PHOTOS,
CollectionType.HOMEVIDEOS,
CollectionType.MUSICVIDEOS,
CollectionType.TRAILERS,
-> {
HomeRowViewOptions(
heightDp = Cards.HEIGHT_EPISODE,
aspectRatio = AspectRatio.WIDE,
)
}
CollectionType.LIVETV,
-> {
HomeRowViewOptions.liveTvDefault
}
CollectionType.MOVIES,
CollectionType.TVSHOWS,
CollectionType.BOXSETS,
CollectionType.BOOKS,
CollectionType.PLAYLISTS,
CollectionType.FOLDERS,
CollectionType.UNKNOWN,
null,
-> {
HomeRowViewOptions()
}
library?.name?.let { ResArgStringProvider(R.string.recently_added_in, it) }
?: ResStringProvider(R.string.recently_added)
}

View file

@ -223,11 +223,11 @@ class ImageUrlService
imageType = imageType,
maxWidth = maxWidth,
maxHeight = maxHeight,
width = width?.takeIf { it > 0 },
height = height?.takeIf { it > 0 },
width = width,
height = height,
quality = quality,
fillWidth = fillWidth?.takeIf { it > 0 },
fillHeight = fillHeight?.takeIf { it > 0 },
fillWidth = fillWidth,
fillHeight = fillHeight,
tag = tag,
format = format,
percentPlayed = percentPlayed,

View file

@ -1,221 +0,0 @@
package com.github.damontecres.wholphin.services
import android.app.SearchManager
import android.content.Intent
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.nav.Destination
import kotlinx.coroutines.flow.first
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class IntentService
@Inject
constructor(
private val api: ApiClient,
private val serverRepository: ServerRepository,
private val userPreferencesService: UserPreferencesService,
) {
suspend fun parseIntent(intent: Intent): IntentResult {
Timber.v("Parsing intent %s", intent)
Timber.v("Intent extras: %s", intent.extras)
Timber.v("Intent data: %s", intent.data)
val action = intent.action ?: intent.data?.host
if (
intent.getStringParam(INTENT_ITEM_TYPE) == null &&
(action == Intent.ACTION_MAIN || action.isNullOrBlank())
) {
// Normal app launch
return IntentResult.NoOp
}
val result = prepare(intent)
if (result != null) {
return result
}
// Existence of this key implies launching from Play Next channel
if (intent.getStringParam(INTENT_ITEM_TYPE) != null) {
return getDestinationFromChannel(intent)?.let {
IntentResult.Target(
destinations = listOf(it),
addHomeToBackStack = false,
)
} ?: IntentResult.Error("Invalid parameters")
}
if (action == Intent.ACTION_SEARCH || action == "search") {
val query = intent.getStringParam(SearchManager.QUERY)
return IntentResult.Target(listOf(Destination.Search(query ?: "")))
}
val itemId = intent.getStringParam("itemId")?.toUUIDOrNull()
val item =
itemId?.let {
try {
api.userLibraryApi
.getItem(itemId)
.content
.let { BaseItem(it) }
} catch (ex: Exception) {
Timber.w(ex, "Error fetching item %s", itemId)
return IntentResult.Error("Could not fetch item $itemId")
}
}
val itemDestination = item?.destination()
val destinations =
when (action) {
Intent.ACTION_VIEW, "view" -> {
if (itemDestination != null) {
listOf(itemDestination)
} else {
// Will go to the home page
emptyList()
}
}
ACTION_PLAYBACK, "play" -> {
if (itemId == null || itemDestination == null) {
return IntentResult.Error("Cannot start playback with no itemId")
}
val position = intent.getLongParam("position")?.coerceAtLeast(0)
val shuffle = intent.getBooleanExtra("shuffle", false)
val playbackDestination =
Destination.Playback(
itemId = itemId,
positionMs = position ?: 0L,
shuffle = shuffle,
)
if (itemDestination is Destination.Playback) {
listOf(playbackDestination)
} else {
listOf(
itemDestination,
playbackDestination,
)
}
}
else -> {
return IntentResult.Error("Invalid action: ${intent.action}")
}
}
return IntentResult.Target(destinations, true)
}
internal suspend fun prepare(intent: Intent): IntentResult? {
val appPrefs = userPreferencesService.flow.first().appPreferences
val userId = intent.getStringParam(INTENT_USER_ID)?.toUUIDOrNull()
val serverId = intent.getStringParam(INTENT_SERVER_ID)?.toUUIDOrNull()
return if (userId != null && serverId != null) {
Timber.v("Intent switches user")
val user = serverRepository.serverDao.getUser(serverId, userId)
if (user != null && !user.isProtected) {
serverRepository.restoreSession(serverId, userId)
?: IntentResult.Error("Error restoring user")
null
} else {
IntentResult.Error("Cannot switch to specified user")
}
} else {
val profileProtected =
serverRepository.current.value
?.user
?.isProtected == true
if (appPrefs.signInAutomatically && !profileProtected) {
Timber.v("No current user, so restoring last")
val userId = appPrefs.currentUserId?.toUUIDOrNull()
val serverId = appPrefs.currentServerId?.toUUIDOrNull()
if (userId != null && serverId != null) {
val current =
serverRepository.restoreSession(serverId, userId)
?: return IntentResult.Error("Error restoring user")
if (current.user.isProtected) {
IntentResult.Error("Could not auto sign-in, user is protected")
} else {
null
}
} else {
IntentResult.Error("Could not auto sign-in, specify a server & user")
}
} else {
IntentResult.Error("Auto sign-in not enabled or user is protected")
}
}
}
private fun Intent.getStringParam(key: String) = getStringExtra(key) ?: data?.getQueryParameter(key)
private fun Intent.getLongParam(key: String) =
getLongExtra(key, -1).takeIf { it >= 0 } ?: data?.getQueryParameter(key)?.toLongOrNull()
private fun getDestinationFromChannel(intent: Intent): Destination? =
intent.let {
val itemId =
it.getStringExtra(INTENT_ITEM_ID)?.toUUIDOrNull()
val type =
it.getStringExtra(INTENT_ITEM_TYPE)?.let(BaseItemKind::fromNameOrNull)
if (itemId != null && type != null) {
val seriesId = it.getStringExtra(INTENT_SERIES_ID)?.toUUIDOrNull()
val seasonId = it.getStringExtra(INTENT_SEASON_ID)?.toUUIDOrNull()
val episodeNumber = it.getIntExtra(INTENT_EPISODE_NUMBER, -1)
val seasonNumber = it.getIntExtra(INTENT_SEASON_NUMBER, -1)
if (seriesId != null && seasonId != null && episodeNumber >= 0 && seasonNumber >= 0) {
Destination.SeriesOverview(
itemId = seriesId,
type = BaseItemKind.SERIES,
seasonEpisode =
SeasonEpisodeIds(
seasonId = seasonId,
seasonNumber = seasonNumber,
episodeId = itemId,
episodeNumber = episodeNumber,
),
)
} else {
Destination.MediaItem(itemId, type)
}
} else {
null
}
}
companion object {
const val INTENT_ITEM_ID = "itemId"
const val INTENT_ITEM_TYPE = "itemType"
const val INTENT_SERIES_ID = "seriesId"
const val INTENT_EPISODE_NUMBER = "epNum"
const val INTENT_SEASON_NUMBER = "seaNum"
const val INTENT_SEASON_ID = "seaId"
const val INTENT_SERVER_ID = "serverId"
const val INTENT_USER_ID = "userId"
const val ACTION_PLAYBACK = "com.github.damontecres.wholphin.PLAYBACK"
}
}
sealed interface IntentResult {
data class Error(
val message: String,
) : IntentResult
data object NoOp : IntentResult
data class Target(
val destinations: List<Destination>,
val addHomeToBackStack: Boolean = true,
) : IntentResult
}

View file

@ -8,7 +8,8 @@ package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.util.LocalDateTimeSerializer
import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.supportItemKinds
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.sync.Semaphore
@ -22,7 +23,6 @@ import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
@ -60,8 +60,16 @@ class LatestNextUpService
userId = userId,
fields = SlimItemFields,
limit = limit,
mediaTypes = listOf(MediaType.VIDEO),
excludeItemTypes = if (!includeEpisodes) listOf(BaseItemKind.EPISODE) else null,
includeItemTypes =
if (includeEpisodes) {
supportItemKinds
} else {
supportItemKinds
.toMutableSet()
.apply {
remove(BaseItemKind.EPISODE)
}
},
enableTotalRecordCount = false,
)
val items =
@ -135,14 +143,14 @@ class LatestNextUpService
resume: List<BaseItem>,
nextUp: List<BaseItem>,
): List<BaseItem> =
withContext(WholphinDispatchers.IO) {
withContext(Dispatchers.IO) {
val start = System.currentTimeMillis()
val semaphore = Semaphore(3)
val deferred =
nextUp
.filter { it.data.seriesId != null }
.map { item ->
async(WholphinDispatchers.IO) {
async(Dispatchers.IO) {
try {
semaphore.withPermit {
datePlayedService.getLastPlayed(item)

View file

@ -16,12 +16,12 @@ import androidx.work.workDataOf
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.ui.collectLatestIn
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
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 org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
@ -92,7 +92,7 @@ class LatestNextUpSchedulerService
)
// Exposed for testing
internal var dispatcher: CoroutineDispatcher = WholphinDispatchers.IO
internal var dispatcher: CoroutineDispatcher = Dispatchers.IO
init {
serverRepository.current.collectLatestIn(activity.lifecycleScope) { user ->

View file

@ -1,94 +0,0 @@
package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.toServerString
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.model.api.TimerInfoDto
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
/**
* Functions for recording live tv programs
*/
@Singleton
class LiveTvService
@Inject
constructor(
private val api: ApiClient,
) {
/**
* Fetch a tv program by ID
*/
suspend fun fetchProgramForDialog(programId: UUID): BaseItem {
val result =
api.liveTvApi
.getProgram(programId.toServerString())
.content
.let { BaseItem(it) }
return result
}
/**
* Cancel a recording by [timerId]
*
* @param series whether the timerId is a series recording or not
* @param timerId the recording ID
*/
suspend fun cancelRecording(
series: Boolean,
timerId: String?,
): Boolean =
if (timerId != null) {
if (series) {
api.liveTvApi.cancelSeriesTimer(timerId)
} else {
api.liveTvApi.cancelTimer(timerId)
}
true
} else {
false
}
/**
* Record tv program
*
* @param programId the tv program ID
* @param series whether to set up series recording or just a single recording
*/
suspend fun record(
programId: UUID,
series: Boolean,
) {
val d by api.liveTvApi.getDefaultTimer(programId.toServerString())
if (series) {
api.liveTvApi.createSeriesTimer(d)
} else {
val payload =
TimerInfoDto(
id = d.id,
type = d.type,
serverId = d.serverId,
externalId = d.externalId,
channelId = d.channelId,
externalChannelId = d.externalChannelId,
channelName = d.channelName,
programId = d.programId,
externalProgramId = d.externalProgramId,
name = d.name,
overview = d.overview,
startDate = d.startDate,
endDate = d.endDate,
serviceName = d.serviceName,
priority = d.priority,
prePaddingSeconds = d.prePaddingSeconds,
postPaddingSeconds = d.postPaddingSeconds,
isPrePaddingRequired = d.isPrePaddingRequired,
isPostPaddingRequired = d.isPostPaddingRequired,
keepUntil = d.keepUntil,
)
api.liveTvApi.createTimer(payload)
}
}
}

View file

@ -2,14 +2,11 @@ package com.github.damontecres.wholphin.services
import android.content.Context
import android.os.Build
import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.getLogCatLines
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.google.protobuf.MessageLiteOrBuilder
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.serialization.json.Json
@ -25,10 +22,10 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* Send reports to the server such as media info or app logs
* Send media info to the server
*/
@Singleton
class ServerReportService
class MediaReportService
@Inject
constructor(
@param:ApplicationContext private val context: Context,
@ -48,65 +45,45 @@ class ServerReportService
/**
* Fetch the media info and send it to the server
*/
fun sendMediaReportFor(itemId: UUID) {
fun sendReportFor(itemId: UUID) {
ioScope.launchIO(ExceptionHandler(autoToast = true)) {
val item = api.userLibraryApi.getItem(itemId = itemId).content
sendMediaReportFor(item)
sendReportFor(item)
}
}
/**
* Send the media report for the given item
*/
suspend fun sendMediaReportFor(item: BaseItemDto) {
suspend fun sendReportFor(item: BaseItemDto) {
val sources =
item.mediaSources ?: api.userLibraryApi
.getItem(itemId = item.id)
.content.mediaSources
val sourcesJson = json.encodeToString(sources)
val appPreferences = userPreferencesService.getCurrent().appPreferences
val playbackPrefs = userPreferencesService.getCurrent().appPreferences.playbackPreferences
val serverVersion = serverRepository.currentServer?.serverVersion
val deviceProfile =
deviceProfileService.getOrCreateDeviceProfile(appPreferences, serverVersion)
deviceProfileService.getOrCreateDeviceProfile(playbackPrefs, serverVersion)
val deviceProfileJson = json.encodeToString(deviceProfile)
val body =
buildLogHeader("Send media info") + "\n\n" +
"""
playbackPrefs=${appPreferences.playbackPreferences.toStringOneLine()}
experimental=${appPreferences.experimentalPreferences.toStringOneLine()}
mediaSources=$sourcesJson
deviceProfile=$deviceProfileJson
""".trimIndent()
body.chunked(2048).forEach { Timber.w(it) }
Timber.w("End send media info")
val response by api.clientLogApi.logFile(body)
showToast(context, "Sent! Filename=${response.fileName}")
}
fun buildLogHeader(title: String): String {
val serverVersion = serverRepository.currentServer?.serverVersion
return """
$title
Send media info
serverVersion=$serverVersion
clientInfo=$clientInfo
flavor=${BuildConfig.FLAVOR}
deviceInfo=$deviceInfo
manufacturer=${Build.MANUFACTURER}
model=${Build.MODEL}
apiLevel=${Build.VERSION.SDK_INT}
""".trimIndent()
}
suspend fun sendAppLogs() {
val logcat = getLogCatLines().joinToString("\n") { it.text }
val header = buildLogHeader("Send App Logs")
Timber.w(header)
val response by api.clientLogApi.logFile(header + "\n\n" + logcat)
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}")
}
}
private fun MessageLiteOrBuilder.toStringOneLine(): String = toString().replace("\n", ", ").replace("\t", " ")

View file

@ -13,11 +13,8 @@ import androidx.media3.session.MediaSession
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.AudioItem
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.enabled
import com.github.damontecres.wholphin.preferences.get
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
import com.github.damontecres.wholphin.ui.DetailItemFields
import com.github.damontecres.wholphin.ui.gt
import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.main.settings.MoveDirection
import com.github.damontecres.wholphin.ui.onMain
import com.github.damontecres.wholphin.ui.seekBack
@ -27,11 +24,10 @@ import com.github.damontecres.wholphin.util.BlockingList
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.PlaybackItemState
import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener
import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.profile.Codec
import com.github.damontecres.wholphin.util.profile.supportedAudioCodecs
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -39,7 +35,6 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@ -49,7 +44,6 @@ import org.jellyfin.sdk.api.client.extensions.universalAudioApi
import org.jellyfin.sdk.api.sockets.subscribe
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.PlaystateCommand
import org.jellyfin.sdk.model.api.PlaystateMessage
@ -78,7 +72,6 @@ class MusicService
private val playerFactory: PlayerFactory,
private val serverRepository: ServerRepository,
private val imageUrlService: ImageUrlService,
private val userPreferencesService: UserPreferencesService,
) {
private val _state = MutableStateFlow(MusicServiceState.EMPTY)
val state: StateFlow<MusicServiceState> = _state
@ -86,15 +79,8 @@ class MusicService
private val audioFormats by lazy { listOf(*supportedAudioCodecs) }
val player: Player by lazy {
val disableAudioOffload =
runBlocking {
userPreferencesService
.getCurrent()
.appPreferences.experimentalPreferences
.get { disableAudioOffload } ?: false
}
playerFactory
.createAudioPlayer(disableAudioOffload = disableAudioOffload)
.createAudioPlayer()
.also {
it.addListener(MusicPlayerListener(it, _state))
}
@ -106,12 +92,6 @@ class MusicService
private var activityTracker: TrackActivityPlaybackListener? = null
private var websocketJob: Job? = null
private suspend fun preferAc3Surround() =
userPreferencesService
.getCurrent()
.appPreferences.experimentalPreferences
.enabled { preferAc3Surround }
/**
* Start music playback
*
@ -182,7 +162,7 @@ class MusicService
userId = serverRepository.currentUser?.id,
itemId = itemId,
limit = 200,
fields = DetailItemFields,
fields = DefaultItemFields,
).content.items
.map { BaseItem(it, false) }
setQueue(items, false)
@ -197,9 +177,9 @@ class MusicService
items: BlockingList<BaseItem?>,
startIndex: Int,
shuffled: Boolean,
) = withContext(WholphinDispatchers.IO) {
) = withContext(Dispatchers.IO) {
Timber.d("setQueue: %s items, startIndex=%s, shuffled=%s", items.size, startIndex, shuffled)
withContext(WholphinDispatchers.Main) {
withContext(Dispatchers.Main) {
player.setMediaItems(emptyList())
player.shuffleModeEnabled = shuffled
}
@ -215,12 +195,11 @@ class MusicService
shuffled: Boolean,
) {
Timber.d("setQueue: %s items, shuffled=%s", items.size, shuffled)
val preferAc3Surround = preferAc3Surround()
val mediaItems =
items
.filter { it.type == BaseItemKind.AUDIO }
.map { convert(it, preferAc3Surround) }
withContext(WholphinDispatchers.Main) {
.map(::convert)
withContext(Dispatchers.Main) {
player.setMediaItems(mediaItems)
player.shuffleModeEnabled = shuffled
updateQueueSize()
@ -236,9 +215,8 @@ class MusicService
index: Int? = null,
) {
if (item.type == BaseItemKind.AUDIO) {
val preferAc3Surround = preferAc3Surround()
val mediaItem = convert(item, preferAc3Surround)
withContext(WholphinDispatchers.Main) {
val mediaItem = convert(item)
withContext(Dispatchers.Main) {
if (index != null) {
player.addMediaItem(index, mediaItem)
} else {
@ -250,8 +228,6 @@ class MusicService
start()
}
}
} else {
Timber.w("Tried to add non-audio type %s: %s", item.type, item.id)
}
}
@ -264,7 +240,6 @@ class MusicService
list: BlockingList<BaseItem?>,
startIndex: Int,
) = loading {
val preferAc3Surround = preferAc3Surround()
var remaining = startIndex
list.indices
.chunked(25)
@ -275,7 +250,7 @@ class MusicService
list
.getBlocking(it)
?.takeIf { it.type == BaseItemKind.AUDIO }
?.let { convert(it, preferAc3Surround) }
?.let(::convert)
} else {
Timber.v("Skipping $remaining")
remaining--
@ -291,36 +266,12 @@ class MusicService
/**
* Converts a [BaseItem] into a [MediaItem] setting an [AudioItem] as its tag
*/
private fun convert(
audio: BaseItem,
preferAc3Surround: Boolean,
): MediaItem {
val needsAc3Transcode =
preferAc3Surround &&
audio.data.mediaSources
?.firstOrNull()
?.mediaStreams
?.any { stream ->
stream.type == MediaStreamType.AUDIO &&
stream.channels.gt(2)
stream.codec != Codec.Audio.AC3
} == true
private fun convert(audio: BaseItem): MediaItem {
val url =
if (needsAc3Transcode) {
api.universalAudioApi.getUniversalAudioStreamUrl(
itemId = audio.id,
container = listOf("mka"),
transcodingContainer = "mka",
maxAudioChannels = 6,
transcodingAudioChannels = 6,
audioCodec = Codec.Audio.AC3,
)
} else {
api.universalAudioApi.getUniversalAudioStreamUrl(
itemId = audio.id,
container = audioFormats,
)
}
Timber.i("url=%s", url)
val imageUrl =
audio.data.albumId?.let { albumId ->
@ -342,7 +293,7 @@ class MusicService
*/
private suspend fun updateQueueSize() {
// val ids =
// withContext(WholphinDispatchers.Default) {
// withContext(Dispatchers.Default) {
// (0..<player.mediaItemCount).map { player.getMediaItemAt(it).mediaId.toUUID() }
// }
val timeline = onMain { player.currentTimeline }
@ -353,7 +304,7 @@ class MusicService
timeline.getWindow(it, window)
window.mediaItem.mediaId.toUUID()
}.toSet()
withContext(WholphinDispatchers.Main) {
withContext(Dispatchers.Main) {
_state.update {
it.copy(
queueVersion = it.queueVersion + 1,
@ -370,7 +321,7 @@ class MusicService
suspend fun moveQueue(
index: Int,
direction: MoveDirection,
) = withContext(WholphinDispatchers.Main) {
) = withContext(Dispatchers.Main) {
player.moveMediaItem(index, if (direction == MoveDirection.UP) index - 1 else index + 1)
updateQueueSize()
}
@ -381,7 +332,7 @@ class MusicService
suspend fun moveQueue(
index: Int,
newIndex: Int,
) = withContext(WholphinDispatchers.Main) {
) = withContext(Dispatchers.Main) {
player.moveMediaItem(index, newIndex)
updateQueueSize()
}
@ -401,7 +352,7 @@ class MusicService
* Play this item next after the current, ie add the item as the next index in the queue
*/
suspend fun playNext(song: BaseItem) {
val mediaItem = convert(song, preferAc3Surround())
val mediaItem = convert(song)
onMain {
player.addMediaItem(state.value.currentIndex + 1, mediaItem)
if (player.mediaItemCount == 1) {
@ -434,7 +385,7 @@ class MusicService
.subscribe<PlaystateMessage>()
.onEach { message ->
message.data?.let {
withContext(WholphinDispatchers.Main) {
withContext(Dispatchers.Main) {
when (it.command) {
PlaystateCommand.STOP -> {
stop()

View file

@ -6,27 +6,27 @@ 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.collectLatestIn
import com.github.damontecres.wholphin.ui.launchDefault
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.WholphinDispatchers
import com.github.damontecres.wholphin.util.supportedCollectionTypes
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collectLatest
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.exception.InvalidStatusException
import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.CollectionType
@ -52,37 +52,35 @@ class NavDrawerService
private val seerrServerRepository: SeerrServerRepository,
private val musicService: MusicService,
) {
private val _state = MutableStateFlow(NavDrawerItemState())
private val _state = MutableStateFlow(NavDrawerItemState.EMPTY)
val state: StateFlow<NavDrawerItemState> = _state
init {
// Handle updating the nav drawer when the user changes
combine(
serverRepository.currentUserFlow,
serverRepository.currentUserDtoFlow,
seerrServerRepository.active,
) { user, userDto, discoverActive ->
Triple(user, userDto, discoverActive)
}.collectLatestIn(coroutineScope) { (user, userDto, discoverActive) ->
Timber.d(
"User updated: user=%s, userDto=%s, discoverActive=%s",
user?.id,
userDto?.id,
discoverActive,
serverRepository.currentUserFlow
.combine(serverRepository.currentUserDtoFlow) { 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(),
)
try {
if (user != null && userDto != null && user.id == userDto.id) {
updateNavDrawer(user, userDto, discoverActive)
} else {
_state.update { NavDrawerItemState() }
}
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
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)
// Handle when the user has logged into a Seerr server
seerrServerRepository.active
.onEach { discoverActive ->
_state.update { it.copy(discoverEnabled = discoverActive) }
}.launchIn(coroutineScope)
// Handle when music is actively playing or not
coroutineScope.launchDefault {
@ -134,22 +132,13 @@ class NavDrawerService
.content.items
val recordingFolders =
if (tvAccess) {
try {
api.liveTvApi
.getRecordingFolders(userId = userId)
.content.items
.map { it.id }
.toSet()
} catch (ex: InvalidStatusException) {
if (ex.status == 401 || ex.status == 403) {
Timber.w("Got HTTP %s querying for recording folders", ex.status)
emptySet()
} else {
throw ex
}
}
} else {
emptySet()
setOf()
}
val libraries =
userViews
@ -189,13 +178,8 @@ class NavDrawerService
suspend fun updateNavDrawer(
user: JellyfinUser,
userDto: UserDto,
discoverActive: Boolean,
) {
val builtins =
buildList {
add(NavDrawerItem.Favorites)
if (discoverActive) add(NavDrawerItem.Discover)
}
val builtins = listOf(NavDrawerItem.Favorites, NavDrawerItem.Discover)
val allLibraries = getAllUserLibraries(user.id, userDto.tvAccess)
val libraries =
allLibraries
@ -220,7 +204,7 @@ class NavDrawerService
val allItems = builtins + libraries
val navDrawerPins =
withContext(WholphinDispatchers.IO) {
withContext(Dispatchers.IO) {
serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId }
}
@ -245,18 +229,21 @@ class NavDrawerService
it.copy(
items = items,
moreItems = moreItems,
allLibraries = allLibraries,
)
}
}
}
data class NavDrawerItemState(
val items: List<NavDrawerItem> = emptyList(),
val moreItems: List<NavDrawerItem> = emptyList(),
val nowPlayingEnabled: Boolean = false,
val nowPlayingTitle: String? = null,
val allLibraries: List<Library> = emptyList(),
)
val items: List<NavDrawerItem>,
val moreItems: List<NavDrawerItem>,
val discoverEnabled: Boolean,
val nowPlayingEnabled: Boolean,
val nowPlayingTitle: String?,
) {
companion object {
val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false, false, null)
}
}
val UserDto.tvAccess: Boolean get() = policy?.enableLiveTvAccess == true

View file

@ -24,21 +24,19 @@ import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.exoplayer.video.MediaCodecVideoRenderer
import androidx.media3.exoplayer.video.VideoRendererEventListener
import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.media3.session.MediaSession
import com.github.damontecres.wholphin.mpv.MpvPlayer
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AssPlaybackMode
import com.github.damontecres.wholphin.preferences.MediaExtensionStatus
import com.github.damontecres.wholphin.preferences.PlaybackPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.get
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.mpv.MpvPlayer
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.peerless2012.ass.media.AssHandler
import io.github.peerless2012.ass.media.factory.AssRenderersFactory
import io.github.peerless2012.ass.media.kt.withAssMkvSupport
import io.github.peerless2012.ass.media.parser.AssSubtitleParserFactory
import io.github.peerless2012.ass.media.type.AssRenderType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import timber.log.Timber
@ -62,10 +60,9 @@ class PlayerFactory
suspend fun createVideoPlayer(
backend: PlayerBackend,
appPreferences: AppPreferences,
prefs: PlaybackPreferences,
): PlayerCreation {
val prefs = appPreferences.playbackPreferences
withContext(WholphinDispatchers.Main) {
withContext(Dispatchers.Main) {
if (currentPlayer?.isReleased == false) {
Timber.w("Player was not released before trying to create a new one!")
currentPlayer?.release()
@ -107,7 +104,6 @@ class PlayerFactory
WholphinRenderersFactory(context, decodeAv1)
.setEnableDecoderFallback(true)
.setExtensionRendererMode(rendererMode)
val mediaSourceFactory =
if (useLibAss) {
val renderType =
@ -132,15 +128,7 @@ class PlayerFactory
extractorsFactory,
)
}
val disableAudioOffload =
appPreferences.experimentalPreferences.get { disableAudioOffload } ?: false
val tunneling =
appPreferences.experimentalPreferences.get { videoTunnelingEnabled }
val trackSelector =
createTrackSelector(
tunneling = tunneling,
disableAudioOffload = disableAudioOffload,
)
val trackSelector = createTrackSelector()
ExoPlayer
.Builder(context)
@ -150,7 +138,7 @@ class PlayerFactory
.build()
.apply {
assHandler?.init(this)
withContext(WholphinDispatchers.Main) {
withContext(Dispatchers.Main) {
setAudioAttributes(
AudioAttributes
.Builder()
@ -170,10 +158,7 @@ class PlayerFactory
return PlayerCreation(newPlayer, assHandler)
}
fun createAudioPlayer(
disableAudioOffload: Boolean,
extensions: MediaExtensionStatus = MediaExtensionStatus.MES_FALLBACK,
): ExoPlayer {
fun createAudioPlayer(extensions: MediaExtensionStatus = MediaExtensionStatus.MES_FALLBACK): ExoPlayer {
val rendererMode =
when (extensions) {
MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
@ -191,7 +176,7 @@ class PlayerFactory
OkHttpDataSource.Factory(authOkHttpClient),
extractorsFactory,
)
val trackSelector = createTrackSelector(disableAudioOffload = disableAudioOffload)
val trackSelector = createTrackSelector()
return ExoPlayer
.Builder(context)
.setMediaSourceFactory(mediaSourceFactory)
@ -202,10 +187,9 @@ class PlayerFactory
it.setAudioAttributes(
AudioAttributes
.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.build(),
true,
false,
)
}
}
@ -215,33 +199,18 @@ class PlayerFactory
.setConstantBitrateSeekingEnabled(true)
.setConstantBitrateSeekingAlwaysEnabled(true)
private fun createTrackSelector(
tunneling: Boolean? = null,
disableAudioOffload: Boolean = false,
) = DefaultTrackSelector(context).apply {
val offloadMode =
if (disableAudioOffload) {
AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_DISABLED
} else {
AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED
}
private fun createTrackSelector() =
DefaultTrackSelector(context).apply {
setParameters(
buildUponParameters()
.apply {
tunneling?.let { setTunnelingEnabled(tunneling) }
}.setAudioOffloadPreferences(
.setAudioOffloadPreferences(
AudioOffloadPreferences
.Builder()
.setAudioOffloadMode(offloadMode)
.setAudioOffloadMode(AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED)
.build(),
),
)
}
fun createMediaSession(player: Player) =
MediaSession
.Builder(context, player)
.build()
}
val Player.isReleased: Boolean

View file

@ -8,20 +8,21 @@ import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.data.model.PlaylistInfo
import com.github.damontecres.wholphin.data.model.PlaylistItem
import com.github.damontecres.wholphin.ui.DetailItemFields
import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.components.baseItemKinds
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.gt
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.playback.playable
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.TransformList
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
import org.jellyfin.sdk.api.client.extensions.playlistsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.videosApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
@ -65,7 +66,7 @@ class PlaylistCreator
GetEpisodesRequest(
seriesId = seriesId,
seasonId = seasonId,
fields = DetailItemFields,
fields = DefaultItemFields,
startItemId = episodeId,
sortBy = if (shuffled) ItemSortBy.RANDOM else null,
limit = Playlist.MAX_SIZE,
@ -77,7 +78,7 @@ class PlaylistCreator
.convertAndAddParts(false)
val startIndex =
episodeId?.let { episodes.indexOfFirstOrNull { it.id == episodeId } } ?: 0
return Playlist(episodes.subList(startIndex, episodes.size))
return Playlist(episodes, startIndex)
}
/**
@ -94,7 +95,7 @@ class PlaylistCreator
GetItemsRequest(
userId = serverRepository.currentUser?.id,
parentId = playlistId,
fields = DetailItemFields,
fields = DefaultItemFields,
startIndex = startIndex,
limit = Playlist.MAX_SIZE,
sortBy = listOf(sortAndDirection.sort),
@ -103,7 +104,7 @@ class PlaylistCreator
),
)
val items = GetItemsRequestHandler.execute(api, request).content.items
return Playlist(items.convertAndAddParts())
return Playlist(items.convertAndAddParts(), 0)
}
private suspend fun createFromCollection(
@ -135,7 +136,7 @@ class PlaylistCreator
excludeItemIds = listOf(item.id),
sortBy = sortAndDirection?.let { listOf(sortAndDirection.sort) },
sortOrder = sortAndDirection?.let { listOf(sortAndDirection.direction) },
fields = DetailItemFields,
fields = DefaultItemFields,
startIndex = startIndex,
limit = Playlist.MAX_SIZE,
excludeLocationTypes = listOf(LocationType.VIRTUAL),
@ -146,7 +147,7 @@ class PlaylistCreator
.execute(api, request)
.content.items
.convertAndAddParts()
return Playlist(items)
return Playlist(items, 0)
}
/**
@ -201,20 +202,6 @@ class PlaylistCreator
BaseItemKind.SEASON -> {
val seriesId = item.seriesId
if (seriesId != null) {
if (shuffled) {
api.tvShowsApi
.getEpisodes(
seriesId = seriesId,
seasonId = item.id,
limit = Playlist.MAX_SIZE,
sortBy = ItemSortBy.RANDOM,
fields = DetailItemFields,
).content.items
.convertAndAddParts()
.let {
PlaylistCreationResult.Success(Playlist(it))
}
} else {
PlaylistCreationResult.Success(
createFromEpisode(
seriesId = seriesId,
@ -223,50 +210,20 @@ class PlaylistCreator
shuffled = shuffled,
),
)
}
} else {
PlaylistCreationResult.Error(null, "Episode has no seriesId")
}
}
BaseItemKind.SERIES -> {
if (shuffled) {
api.tvShowsApi
.getEpisodes(
seriesId = item.id,
limit = Playlist.MAX_SIZE,
sortBy = ItemSortBy.RANDOM,
fields = DetailItemFields,
).content.items
.convertAndAddParts()
.let {
PlaylistCreationResult.Success(Playlist(it))
}
} else {
val result by api.tvShowsApi.getNextUp(seriesId = item.id)
val nextUp =
result.items.firstOrNull() ?: api.tvShowsApi
.getEpisodes(
item.id,
limit = 1,
).content.items
.firstOrNull()
if (nextUp != null) {
PlaylistCreationResult.Success(
createFromEpisode(
seriesId = item.id,
seasonId = null,
episodeId = nextUp.id,
episodeId = null,
shuffled = shuffled,
),
)
} else {
PlaylistCreationResult.Error(
null,
"Could not determine next up episode for series: " + item.id,
)
}
}
}
BaseItemKind.PLAYLIST -> {
@ -306,7 +263,7 @@ class PlaylistCreator
}.let(::addAll)
}
}
PlaylistCreationResult.Success(Playlist(list))
PlaylistCreationResult.Success(Playlist(list, 0))
}
// Not support yet
@ -342,50 +299,27 @@ class PlaylistCreator
* Get the playlists on the server for a given media type
*/
suspend fun getServerPlaylists(
query: String,
mediaType: MediaType?,
): List<PlaylistInfo> {
val userId =
serverRepository.currentUser?.id ?: throw IllegalStateException("No user found")
scope: CoroutineScope,
): List<PlaylistInfo?> {
val request =
GetItemsRequest(
searchTerm = query.takeIf { it.isNotBlank() },
includeItemTypes = listOf(BaseItemKind.PLAYLIST),
mediaTypes = mediaType?.let { listOf(mediaType) },
recursive = true,
limit = 25,
sortBy = listOf(ItemSortBy.DATE_LAST_CONTENT_ADDED),
sortOrder = listOf(SortOrder.DESCENDING),
)
val playlists = GetItemsRequestHandler.execute(api, request).content.items
return playlists
.mapNotNull { playlist ->
try {
val response = api.playlistsApi.getPlaylistUser(playlist.id, userId).content
if (response.canEdit) {
val pager = ApiRequestPager(api, request, GetItemsRequestHandler, scope).init()
return TransformList(pager) {
it?.let {
PlaylistInfo(
id = playlist.id,
name = playlist.name ?: context.getString(R.string.unknown),
count = playlist.childCount ?: 0,
mediaType = playlist.mediaType,
id = it.id,
name = it.name ?: context.getString(R.string.unknown),
count = it.data.childCount ?: 0,
mediaType = it.data.mediaType,
)
} else {
null
}
} catch (ex: InvalidStatusException) {
if (ex.status == 404) {
null
} else {
throw ex
}
}
}
// .sortedWith(
// compareBy<PlaylistInfo> {
// SearchRelevance.score(it.name, BaseItemKind.PLAYLIST, query)
// }.thenBy { it.name },
// )
}
suspend fun createServerPlaylist(
name: String,

View file

@ -8,9 +8,9 @@ import android.os.Looper
import android.view.Display
import com.github.damontecres.wholphin.MainActivity
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.WholphinDispatchers
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
@ -38,7 +38,7 @@ class RefreshRateService
stream: MediaStream,
switchRefreshRate: Boolean,
switchResolution: Boolean,
) = withContext(WholphinDispatchers.IO) {
) = withContext(Dispatchers.IO) {
if (!switchRefreshRate && !switchResolution) {
Timber.v("Not switching either refresh rate nor resolution")
return@withContext

View file

@ -1,34 +0,0 @@
package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.data.RememberedTabDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.RememberedTab
import com.github.damontecres.wholphin.util.WholphinDispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RememberedTabService
@Inject
constructor(
private val serverRepository: ServerRepository,
private val rememberedTabDao: RememberedTabDao,
) {
suspend fun getRememberedTab(itemId: String): Int? =
withContext(WholphinDispatchers.IO) {
serverRepository.currentUser?.rowId?.let { userId ->
rememberedTabDao.getRememberedTab(userId, itemId)?.index
}
}
suspend fun saveRememberedTab(
itemId: String,
tabIndex: Int,
): Unit =
withContext(WholphinDispatchers.IO) {
serverRepository.currentUser?.rowId?.let { userId ->
rememberedTabDao.save(RememberedTab(userId, itemId, tabIndex))
}
}
}

View file

@ -1,9 +1,6 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import coil3.imageLoader
import coil3.request.ImageRequest
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
@ -13,10 +10,10 @@ 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 com.github.damontecres.wholphin.util.WholphinDispatchers
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
@ -60,7 +57,6 @@ class ScreensaverService
val keepScreenOn = MutableStateFlow(false)
private var waitJob: Job? = null
private var dimJob: Job? = null
init {
userPreferencesService.flow
@ -69,14 +65,7 @@ class ScreensaverService
val enabled =
prefs.appPreferences.interfacePreferences.screensaverPreference.enabled
keepScreenOnInternal(enabled)
ScreensaverState(
enabled = enabled,
enabledTemp = false,
active = false,
paused = false,
dimEnabled = prefs.appPreferences.interfacePreferences.screensaverPreference.dimEnabled,
dimActive = false,
)
ScreensaverState(enabled, false, false, false)
}
}.launchIn(scope)
}
@ -90,10 +79,7 @@ class ScreensaverService
// Timber.v("pulse")
_state.update {
if (!it.active) {
it.copy(
active = false,
dimActive = false,
)
it.copy(active = false)
} else {
it
}
@ -108,30 +94,7 @@ class ScreensaverService
.appPreferences.interfacePreferences.screensaverPreference.startDelay.milliseconds
delay(startDelay)
_state.update {
it.copy(
active = true,
dimActive = it.dimEnabled,
)
}
}
}
}
dimJob?.cancel()
if (_state.value.dimEnabled) {
_state.update {
it.copy(dimActive = false)
}
if (!_state.value.paused) {
dimJob =
scope.launch(ExceptionHandler()) {
val startDelay =
userPreferencesService
.getCurrent()
.appPreferences.interfacePreferences.screensaverPreference.startDelay.milliseconds
delay(startDelay)
Timber.v("state dim=%s", _state.value)
_state.update {
it.copy(dimActive = !_state.value.paused)
it.copy(active = true)
}
}
}
@ -146,7 +109,6 @@ class ScreensaverService
it.copy(
enabledTemp = true,
active = true,
dimActive = true,
)
}
}
@ -159,13 +121,9 @@ class ScreensaverService
it.copy(
enabledTemp = false,
active = false,
dimActive = false,
)
}
if (cancelJob) {
waitJob?.cancel()
dimJob?.cancel()
}
if (cancelJob) waitJob?.cancel()
}
/**
@ -173,24 +131,17 @@ class ScreensaverService
*/
fun keepScreenOn(keep: Boolean) {
scope.launchDefault {
val screensaverEnabled = state.value.enabled
val dimEnabled = state.value.dimEnabled
val screensaverEnabled = _state.value.enabled
Timber.d("Keep screen on: %s, screensaverEnabled=%s", keep, screensaverEnabled)
if (screensaverEnabled || dimEnabled) {
if (screensaverEnabled) {
// Page is requesting to keep screen on, so we don't wait to show the screensaver
_state.update {
it.copy(
active = false,
dimActive = false,
paused = keep,
)
it.copy(active = false, paused = keep)
}
if (!keep) {
pulse()
}
}
if (!screensaverEnabled) {
// If in-app screensaver is not enabled, send keep screen on to the OS
} else {
keepScreenOnInternal(keep)
}
}
@ -273,7 +224,7 @@ class ScreensaverService
if (index > pager.lastIndex) index = 0
}
}
}.flowOn(WholphinDispatchers.Default).cancellable()
}.flowOn(Dispatchers.Default).cancellable()
private suspend fun createPager(): ApiRequestPager<GetItemsRequest> {
val prefs =
@ -294,11 +245,6 @@ class ScreensaverService
)
return ApiRequestPager(api, request, GetItemsRequestHandler, scope).init()
}
companion object {
val enterAnimation = fadeIn(animationSpec = tween(durationMillis = 1000))
val exitAnimation = fadeOut(animationSpec = tween(durationMillis = 500))
}
}
data class ScreensaverState(
@ -306,10 +252,6 @@ data class ScreensaverState(
val enabledTemp: Boolean,
val active: Boolean,
val paused: Boolean,
val dimEnabled: Boolean = false,
val dimActive: Boolean = false,
) {
val show get() = (enabled || enabledTemp) && active && !paused
val showDim get() = dimEnabled && dimActive && !paused
}

View file

@ -6,21 +6,13 @@ import com.github.damontecres.wholphin.api.seerr.model.CreditCrew
import com.github.damontecres.wholphin.api.seerr.model.MediaInfo
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails
import com.github.damontecres.wholphin.api.seerr.model.MovieResult
import com.github.damontecres.wholphin.api.seerr.model.RootFolder
import com.github.damontecres.wholphin.api.seerr.model.SearchGet200ResponseResultsInner
import com.github.damontecres.wholphin.api.seerr.model.ServiceProfile
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
import com.github.damontecres.wholphin.api.seerr.model.TvResult
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.SeerrAvailability
import com.github.damontecres.wholphin.data.model.SeerrItemType
import com.github.damontecres.wholphin.data.model.SeerrPermission
import com.github.damontecres.wholphin.data.model.hasPermission
import com.github.damontecres.wholphin.ui.detail.discover.SeerrProfile
import com.github.damontecres.wholphin.ui.detail.discover.SeerrRequestData
import com.github.damontecres.wholphin.ui.detail.discover.SeerrRootFolder
import com.github.damontecres.wholphin.ui.formatBytes
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.toLocalDate
import kotlinx.coroutines.flow.first
@ -28,7 +20,6 @@ import kotlinx.coroutines.flow.firstOrNull
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@ -124,7 +115,21 @@ class SeerrService
}
BaseItemKind.PERSON -> {
personCredits(it)
api.personApi
.personPersonIdCombinedCreditsGet(personId = it)
.let { credits ->
val cast =
credits.cast
?.take(25)
?.map { createDiscoverItem(it) }
.orEmpty()
val crew =
credits.crew
?.take(25)
?.map { createDiscoverItem(it) }
.orEmpty()
cast + crew
}
}
else -> {
@ -136,50 +141,6 @@ class SeerrService
null
}
/**
* Get the combined movie & TV credits of a person as [DiscoverItem]s
*
* The two lists returned by the API are merged, de-duplicated by id since a person can be
* both cast and crew on the same title, and sorted by release date descending with undated
* entries last
*/
suspend fun personCredits(personId: Int): List<DiscoverItem> =
api.personApi
.personPersonIdCombinedCreditsGet(personId = personId)
.let { credits ->
val cast = credits.cast?.map { createDiscoverItem(it) }.orEmpty()
val crew = credits.crew?.map { createDiscoverItem(it) }.orEmpty()
(cast + crew)
.distinctBy { it.id }
.sortedByDescending { it.releaseDate }
}
/**
* Create a [DiscoverItem] for a person in the library so that the full grid of their
* credits can be opened from their page
*
* @return the item or null if the person has no TMDB id
*/
fun discoverPerson(item: BaseItem): DiscoverItem? =
item.data.providerIds
?.get("Tmdb")
?.toIntOrNull()
?.let {
DiscoverItem(
id = it,
type = SeerrItemType.PERSON,
title = item.name,
subtitle = null,
overview = item.data.overview,
availability = SeerrAvailability.UNKNOWN,
releaseDate = null,
posterUrl = null,
backDropUrl = null,
logoUrl = null,
jellyfinItemId = null,
)
}
suspend fun getTvSeries(item: BaseItem): TvDetails? =
if (active.first()) {
item.data.providerIds
@ -192,15 +153,20 @@ class SeerrService
null
}
suspend fun createImageUrl(
private suspend fun createImageUrl(
imageType: ImageType,
path: String?,
mediaInfo: MediaInfo?,
backdropHeight: Int = 1080,
backdropWidth: Int = 1920,
): String? {
if (mediaInfo != null) {
val itemId = mediaInfo.jellyfinId
val itemId =
if (mediaInfo.jellyfinMediaId.isNotNullOrBlank()) {
mediaInfo.jellyfinMediaId.toUUIDOrNull()
} else if (mediaInfo.jellyfinMediaId4k.isNotNullOrBlank()) {
mediaInfo.jellyfinMediaId4k.toUUIDOrNull()
} else {
null
}
if (itemId != null) {
return imageUrlService.getItemImageUrl(
itemId = itemId,
@ -208,7 +174,6 @@ class SeerrService
)
}
}
return path?.takeIf { it.isNotNullOrBlank() }?.let {
val current = seerrServerRepository.current.firstOrNull() ?: return null
val cacheImages = current.serverConfig.cacheImages == true
val base =
@ -220,132 +185,11 @@ class SeerrService
val prefix =
when (imageType) {
ImageType.PRIMARY -> "/t/p/w500"
ImageType.BACKDROP -> "/t/p/w${backdropWidth}_and_h${backdropHeight}_multi_faces"
ImageType.BACKDROP -> "/t/p/w1920_and_h1080_multi_faces"
else -> throw IllegalArgumentException("Image type not supported: $imageType")
}
return "${base}${prefix}$path"
}
}
suspend fun getProfilesAndFolders(type: SeerrItemType): SeerrRequestData {
var result = SeerrRequestData()
val current = seerrServerRepository.current.firstOrNull()
if (current != null && current.config.hasPermission(SeerrPermission.REQUEST_ADVANCED)) {
if (type == SeerrItemType.MOVIE) {
val radarrs = api.serviceApi.serviceRadarrGet()
val radarr =
radarrs.firstOrNull { it.isDefault && !it.is4k }
?: radarrs.firstOrNull { !it.is4k }
val radarr4k =
radarrs.firstOrNull { it.isDefault && it.is4k }
?: radarrs.firstOrNull { it.is4k }
if (radarr != null && radarr.id != null) {
val settings = api.serviceApi.serviceRadarrRadarrIdGet(radarr.id)
setProfilesAndFolder(
settings.profiles,
settings.rootFolders,
settings.server?.activeProfileId,
settings.server?.activeDirectory,
) { profiles, rootFolders ->
result =
result.copy(
serverId = radarr.id,
profiles = profiles,
rootFolders = rootFolders,
)
}
}
if (radarr4k != null && radarr4k.id != null) {
val settings = api.serviceApi.serviceRadarrRadarrIdGet(radarr4k.id)
setProfilesAndFolder(
settings.profiles,
settings.rootFolders,
settings.server?.activeProfileId,
settings.server?.activeDirectory,
) { profiles, rootFolders ->
result =
result.copy(
server4kId = radarr4k.id,
profiles4k = profiles,
rootFolders4k = rootFolders,
)
}
}
} else {
val sonarrs = api.serviceApi.serviceSonarrGet()
val sonarr =
sonarrs.firstOrNull { it.isDefault && !it.is4k }
?: sonarrs.firstOrNull { !it.is4k }
val sonarr4k =
sonarrs.firstOrNull { it.isDefault && it.is4k }
?: sonarrs.firstOrNull { it.is4k }
if (sonarr != null && sonarr.id != null) {
val settings = api.serviceApi.serviceSonarrSonarrIdGet(sonarr.id)
setProfilesAndFolder(
settings.profiles,
settings.rootFolders,
settings.server?.activeProfileId,
settings.server?.activeDirectory,
) { profiles, rootFolders ->
result =
result.copy(
serverId = sonarr.id,
profiles = profiles,
rootFolders = rootFolders,
)
}
}
if (sonarr4k != null && sonarr4k.id != null) {
val settings = api.serviceApi.serviceSonarrSonarrIdGet(sonarr4k.id)
setProfilesAndFolder(
settings.profiles,
settings.rootFolders,
settings.server?.activeProfileId,
settings.server?.activeDirectory,
) { profiles, rootFolders ->
result =
result.copy(
server4kId = sonarr4k.id,
profiles4k = profiles,
rootFolders4k = rootFolders,
)
}
}
}
}
return result
}
private fun setProfilesAndFolder(
profiles: List<ServiceProfile>?,
rootFolders: List<RootFolder>?,
activeProfileId: Int?,
activeDirectory: String?,
setter: (List<SeerrProfile>, List<SeerrRootFolder>) -> Unit,
) {
val profiles =
profiles.orEmpty().mapNotNull {
if (it.id != null && it.name != null) {
SeerrProfile(it.id, it.name, activeProfileId == it.id)
} else {
null
}
}
val rootFolders =
rootFolders.orEmpty().mapNotNull {
if (it.id != null && it.path != null) {
val freeSpace = it.freeSpace?.let { formatBytes(it) } ?: ""
SeerrRootFolder(it.id, it.path, freeSpace, activeDirectory == it.path)
} else {
null
}
}
setter.invoke(profiles, rootFolders)
}
suspend fun createDiscoverItem(item: Any): DiscoverItem =
when (item) {
@ -372,8 +216,7 @@ class SeerrService
releaseDate = toLocalDate(movie.releaseDate),
posterUrl = createImageUrl(ImageType.PRIMARY, movie.posterPath, movie.mediaInfo),
backDropUrl = createImageUrl(ImageType.BACKDROP, movie.backdropPath, movie.mediaInfo),
logoUrl = createImageUrl(ImageType.LOGO, null, movie.mediaInfo),
jellyfinItemId = movie.mediaInfo?.jellyfinId,
jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
)
suspend fun createDiscoverItem(movie: MovieDetails): DiscoverItem =
@ -389,8 +232,7 @@ class SeerrService
releaseDate = toLocalDate(movie.releaseDate),
posterUrl = createImageUrl(ImageType.PRIMARY, movie.posterPath, movie.mediaInfo),
backDropUrl = createImageUrl(ImageType.BACKDROP, movie.backdropPath, movie.mediaInfo),
logoUrl = createImageUrl(ImageType.LOGO, null, movie.mediaInfo),
jellyfinItemId = movie.mediaInfo?.jellyfinId,
jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
)
suspend fun createDiscoverItem(tv: TvResult): DiscoverItem =
@ -406,8 +248,7 @@ class SeerrService
releaseDate = toLocalDate(tv.firstAirDate),
posterUrl = createImageUrl(ImageType.PRIMARY, tv.posterPath, tv.mediaInfo),
backDropUrl = createImageUrl(ImageType.BACKDROP, tv.backdropPath, tv.mediaInfo),
logoUrl = createImageUrl(ImageType.LOGO, null, tv.mediaInfo),
jellyfinItemId = tv.mediaInfo?.jellyfinId,
jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
)
suspend fun createDiscoverItem(tv: TvDetails): DiscoverItem =
@ -423,8 +264,7 @@ class SeerrService
releaseDate = toLocalDate(tv.firstAirDate),
posterUrl = createImageUrl(ImageType.PRIMARY, tv.posterPath, tv.mediaInfo),
backDropUrl = createImageUrl(ImageType.BACKDROP, tv.backdropPath, tv.mediaInfo),
logoUrl = createImageUrl(ImageType.LOGO, null, tv.mediaInfo),
jellyfinItemId = tv.mediaInfo?.jellyfinId,
jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
)
suspend fun createDiscoverItem(search: SeerrSearchResult): DiscoverItem =
@ -440,8 +280,7 @@ class SeerrService
releaseDate = toLocalDate(search.releaseDate ?: search.firstAirDate),
posterUrl = createImageUrl(ImageType.PRIMARY, search.posterPath, search.mediaInfo),
backDropUrl = createImageUrl(ImageType.BACKDROP, search.backdropPath, search.mediaInfo),
logoUrl = createImageUrl(ImageType.LOGO, null, search.mediaInfo),
jellyfinItemId = search.mediaInfo?.jellyfinId,
jellyfinItemId = search.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
)
suspend fun createDiscoverItem(credit: CreditCast): DiscoverItem =
@ -454,7 +293,7 @@ class SeerrService
availability =
SeerrAvailability.from(credit.mediaInfo?.status)
?: SeerrAvailability.UNKNOWN,
releaseDate = toLocalDate(credit.releaseDate ?: credit.firstAirDate),
releaseDate = toLocalDate(credit.firstAirDate),
posterUrl =
createImageUrl(
ImageType.PRIMARY,
@ -467,8 +306,7 @@ class SeerrService
credit.backdropPath,
credit.mediaInfo,
),
logoUrl = createImageUrl(ImageType.LOGO, null, credit.mediaInfo),
jellyfinItemId = credit.mediaInfo?.jellyfinId,
jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
)
suspend fun createDiscoverItem(credit: CreditCrew): DiscoverItem =
@ -481,7 +319,7 @@ class SeerrService
availability =
SeerrAvailability.from(credit.mediaInfo?.status)
?: SeerrAvailability.UNKNOWN,
releaseDate = toLocalDate(credit.releaseDate ?: credit.firstAirDate),
releaseDate = toLocalDate(credit.firstAirDate),
posterUrl =
createImageUrl(
ImageType.PRIMARY,
@ -494,13 +332,6 @@ class SeerrService
credit.backdropPath,
credit.mediaInfo,
),
logoUrl = createImageUrl(ImageType.LOGO, null, credit.mediaInfo),
jellyfinItemId = credit.mediaInfo?.jellyfinId,
jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
)
}
val MediaInfo.jellyfinId: UUID?
get() = jellyfinMediaId4k?.toUUIDOrNull() ?: jellyfinMediaId?.toUUIDOrNull()
val MediaInfo.jellyfinIdAsString: String?
get() = jellyfinMediaId4k ?: jellyfinMediaId

View file

@ -15,7 +15,6 @@ import com.github.damontecres.wholphin.ui.showToast
import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.jellyfin.sdk.api.client.ApiClient
@ -95,8 +94,6 @@ class ServerEventListener
.joinToString("\n")
showToast(context, toast, Toast.LENGTH_LONG)
}
}.catch { ex ->
Timber.e(ex, "Error in websocket subscription")
}.launchIn(activity.lifecycleScope)
}

View file

@ -27,6 +27,9 @@ class SetupNavigationManager
fun navigateTo(destination: SetupDestination) {
backStack[0] = destination
log()
if (destination !is SetupDestination.AppContent) {
navigationManager.reloadHome()
}
}
private fun log() {

View file

@ -5,9 +5,7 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.preferences.SubtitleModePreference
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.UserProfileSettings
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty
import org.jellyfin.sdk.model.api.BaseItemDto
@ -137,8 +135,8 @@ class StreamChoiceService
val seriesLang =
playbackLanguageChoice?.audioLanguage?.takeIf { it.isNotNullOrBlank() }
// If the user has chosen a different language for the series, prefer that
val audioLanguage =
seriesLang ?: getPreferredLanguage(MediaStreamType.AUDIO, prefs, userConfig)
val audioLanguage = seriesLang ?: userConfig?.audioLanguagePreference
if (audioLanguage.isNotNullOrBlank()) {
val sorted =
candidates.sortedWith(compareBy<MediaStream> { it.language }.thenByDescending { it.channels })
@ -228,17 +226,23 @@ class StreamChoiceService
): MediaStream? {
if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) {
return null
}
} else if (itemPlayback?.subtitleIndex == TrackIndex.ONLY_FORCED) {
// Client-side manual override: User selected "Only Forced" in player menu
val seriesLang =
playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() }
val subtitleLanguage =
seriesLang ?: getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig)
if (itemPlayback?.subtitleIndex == TrackIndex.ONLY_FORCED) {
// Client-side manual override: User selected "Only Forced" in player menu
(seriesLang ?: userConfig?.subtitleLanguagePreference)
?.takeIf { it.isNotNullOrBlank() }
return findForcedTrack(candidates, subtitleLanguage, audioStreamLang)
} else if (itemPlayback?.subtitleIndexEnabled == true) {
return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex }
} else {
val seriesLang =
playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() }
val subtitleLanguage =
(seriesLang ?: userConfig?.subtitleLanguagePreference)
?.takeIf { it.isNotNullOrBlank() }
val subtitleMode =
when {
playbackLanguageChoice?.subtitlesDisabled == false && seriesLang != null -> {
@ -254,17 +258,9 @@ class StreamChoiceService
else -> {
// Fallback to the user's preference
when (prefs.userPreferences?.subtitleMode) {
SubtitleModePreference.USE_USER_PROFILE -> userConfig?.subtitleMode
SubtitleModePreference.DEFAULT -> SubtitlePlaybackMode.DEFAULT
SubtitleModePreference.SMART -> SubtitlePlaybackMode.SMART
SubtitleModePreference.ONLY_FORCED -> SubtitlePlaybackMode.ONLY_FORCED
SubtitleModePreference.ALWAYS -> SubtitlePlaybackMode.ALWAYS
SubtitleModePreference.NONE -> SubtitlePlaybackMode.NONE
null -> SubtitlePlaybackMode.DEFAULT
userConfig?.subtitleMode ?: SubtitlePlaybackMode.DEFAULT
}
}
} ?: SubtitlePlaybackMode.DEFAULT
val candidates =
candidates
.sortedWith(
@ -281,13 +277,11 @@ class StreamChoiceService
SubtitlePlaybackMode.ALWAYS -> {
if (subtitleLanguage.isNotNullOrBlank()) {
candidates.firstOrNull {
// Prefer non-forced first
!it.isForced && it.language.equalsLangOrUnknown(subtitleLanguage)
} ?: candidates.firstOrNull {
it.language.equalsLangOrUnknown(subtitleLanguage)
it.language.equals(subtitleLanguage, true) ||
it.language.isUnknown
}
} else {
candidates.firstOrNull { !it.isForced } ?: candidates.firstOrNull()
candidates.firstOrNull()
}
}
@ -377,38 +371,3 @@ private val String?.isUnknown: Boolean
this.equals("undetermined", true) ||
this.equals("mul", true) ||
this.equals("zxx", true)
private fun String?.equalsLangOrUnknown(lang: String): Boolean = equals(lang, ignoreCase = true) || this.isUnknown
/**
* Based on the user's preferences, get their preferred language for audio or subtitles
*
* @param prefs the [UserPreferences]
*/
fun getPreferredLanguage(
type: MediaStreamType,
prefs: UserPreferences,
userConfig: UserConfiguration?,
): String? {
val (pref, profileLang) =
when (type) {
MediaStreamType.AUDIO -> {
prefs.userPreferences?.preferredAudioLanguage to
userConfig?.audioLanguagePreference
}
MediaStreamType.SUBTITLE -> {
prefs.userPreferences?.preferredSubtitleLanguage to
userConfig?.subtitleLanguagePreference
}
else -> {
throw IllegalArgumentException("Only audio or subtitle supported, not $type")
}
}
return when (pref) {
UserProfileSettings.USE_USER_PROFILE -> profileLang
UserProfileSettings.PREFER_ANY_LANGUAGE -> null
else -> pref
}?.takeIf { it.isNotNullOrBlank() }
}

View file

@ -4,11 +4,11 @@ package com.github.damontecres.wholphin.services
import android.content.Context
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.WholphinDispatchers
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
@ -43,7 +43,7 @@ class SuggestionsCache
private val memoryCache =
InMemoryKache<String, CachedSuggestions>(maxSize = 8) {
creationScope = CoroutineScope(WholphinDispatchers.IO)
creationScope = CoroutineScope(Dispatchers.IO)
}
private fun cacheKey(

View file

@ -9,9 +9,9 @@ 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 com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
@ -81,7 +81,7 @@ class SuggestionsWorker
.takeIf { it > 0 }
?: AppPreference.HomePageItems.defaultValue.toInt()
val context = WholphinDispatchers.IO.limitedParallelism(2, "fetchSuggestions")
val context = Dispatchers.IO.limitedParallelism(2, "fetchSuggestions")
if (requestedParentId != null && requestedItemKind != null) {
Timber.d(
"Fetching on-demand suggestions for parent=%s kind=%s",
@ -128,7 +128,7 @@ class SuggestionsWorker
supervisorScope {
supportedViews
.map { view ->
async(WholphinDispatchers.IO) {
async(Dispatchers.IO) {
runCatching {
Timber.v("Fetching suggestions for parent=%s kind=%s", view.id, view.itemKind)
fetchAndCacheSuggestions(
@ -293,7 +293,7 @@ class SuggestionsWorker
limit = freshLimit,
)
}
withContext(WholphinDispatchers.Default) {
withContext(Dispatchers.Default) {
val contextual = contextualDeferred.await()
val random = randomDeferred.await()
val fresh = freshDeferred.await()

View file

@ -14,10 +14,10 @@ import com.github.damontecres.wholphin.preferences.ThemeSongVolume
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.profile.Codec
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow
@ -102,7 +102,7 @@ class ThemeSongPlayer
Codec.Audio.FLAC,
),
)
withContext(WholphinDispatchers.Main) {
withContext(Dispatchers.Main) {
player.apply {
stop()
volume = volumeLevel
@ -127,7 +127,7 @@ class ThemeSongPlayer
scope.launch {
mutex.withLock {
state.value.job?.cancelAndJoin()
withContext(WholphinDispatchers.Main) {
withContext(Dispatchers.Main) {
Timber.v("Stopping theme song")
player.stop()
}

View file

@ -34,7 +34,7 @@ class TrailerService
// TODO would be nice to clean up the trailer name
// ?.replace(item.name ?: "", "")
// ?.removePrefix(" - ")
?: context.resources.getQuantityString(R.plurals.trailers, 1)
?: context.getString(R.string.trailer)
val subtitle =
when (url.toUri().host) {
"youtube.com", "www.youtube.com" -> "YouTube"

View file

@ -1,7 +1,6 @@
package com.github.damontecres.wholphin.services
import android.Manifest
import android.annotation.SuppressLint
import android.content.ContentValues
import android.content.Context
import android.content.Intent
@ -22,8 +21,8 @@ import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.Version
import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@ -62,6 +61,8 @@ class UpdateChecker
private const val APK_MIME_TYPE = "application/vnd.android.package-archive"
private const val PERMISSION_REQUEST_CODE = 123456
private val NOTE_REGEX = Regex("<!-- app-note:(.+) -->")
val ACTIVE = BuildConfig.UPDATING_ENABLED
@ -130,7 +131,7 @@ class UpdateChecker
suspend fun getRelease(version: Version): Release? {
val url =
"https://api.github.com/repos/damontecres/Wholphin/releases/tags/v${version.major}.${version.minor}.${version.patch}"
return withContext(WholphinDispatchers.IO) {
return withContext(Dispatchers.IO) {
val request =
Request
.Builder()
@ -145,7 +146,7 @@ class UpdateChecker
* Get the latest released version
*/
suspend fun getLatestRelease(updateUrl: String): Release? =
withContext(WholphinDispatchers.IO) {
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
@ -157,8 +158,8 @@ class UpdateChecker
private fun getRelease(request: Request): Release? {
return okHttpClient.newCall(request).execute().use {
if (it.isSuccessful) {
val result = Json.parseToJsonElement(it.body.string())
if (it.isSuccessful && it.body != null) {
val result = Json.parseToJsonElement(it.body!!.string())
val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull
val version = Version.tryFromString(name)
val publishedAt =
@ -194,12 +195,11 @@ class UpdateChecker
/**
* Download and install an update
*/
@SuppressLint("RequestInstallPackagesPolicy")
suspend fun installRelease(
release: Release,
callback: DownloadCallback,
) {
withContext(WholphinDispatchers.IO) {
withContext(Dispatchers.IO) {
cleanup()
val request =
Request
@ -208,9 +208,9 @@ class UpdateChecker
.get()
.build()
okHttpClient.newCall(request).execute().use {
if (it.isSuccessful) {
if (it.isSuccessful && it.body != null) {
Timber.v("Request successful for ${release.downloadUrl}")
withContext(WholphinDispatchers.Main) {
withContext(Dispatchers.Main) {
callback.contentLength(it.body.contentLength())
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@ -230,7 +230,7 @@ class UpdateChecker
contentValues,
)
if (uri != null) {
it.body.byteStream().use { input ->
it.body!!.byteStream().use { input ->
resolver.openOutputStream(uri).use { output ->
copyTo(input, output!!, callback = callback)
}
@ -290,7 +290,7 @@ class UpdateChecker
downloadDir.mkdirs()
val targetFile = File(downloadDir, APK_NAME)
targetFile.outputStream().use { output ->
response.body.byteStream().use { input ->
response.body!!.byteStream().use { input ->
copyTo(input, output, callback = callback)
}
}
@ -374,7 +374,7 @@ data class Release(
Regex("https://github.com/\\w*/\\w+/pull/(\\d+)"),
"#$1",
)
// Remove the last line for full changelog since it's just a link
// Remove the last line for full changelog since its just a link
.replace(Regex("\\*\\*Full Changelog\\*\\*.*"), "")
}
@ -387,30 +387,29 @@ interface DownloadCallback {
suspend fun copyTo(
input: InputStream,
out: OutputStream,
bufferSize: Int = 64 * 1024,
bufferSize: Int = 16 * 1024,
callback: DownloadCallback,
): Long =
withContext(WholphinDispatchers.IO) {
): Long {
var bytesCopied: Long = 0
val buffer = ByteArray(bufferSize)
var bytes = input.read(buffer)
while (bytes >= 0) {
out.write(buffer, 0, bytes)
bytesCopied += bytes
withContext(WholphinDispatchers.Main) {
withContext(Dispatchers.Main) {
callback.bytesDownloaded(bytesCopied)
}
bytes = input.read(buffer)
}
return@withContext bytesCopied
}
return bytesCopied
}
fun getDownloadUrl(
assets: JsonArray,
debug: Boolean,
supportedABIs: List<String> = Build.SUPPORTED_ABIS.toList(),
supportedAbis: List<String> = Build.SUPPORTED_ABIS.toList(),
): String? {
val abiSuffix = supportedABIs.firstOrNull().let { if (it != null) "-$it" else "" }
val abiSuffix = supportedAbis.firstOrNull().let { if (it != null) "-$it" else "" }
val releaseSuffix = if (debug) "-debug" else "-release"
val preferredNames =
buildList {

View file

@ -4,9 +4,8 @@ import androidx.datastore.core.DataStore
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
@ -20,10 +19,13 @@ class UserPreferencesService
private val serverRepository: ServerRepository,
private val preferencesDataStore: DataStore<AppPreferences>,
) {
val flow: Flow<UserPreferences> =
preferencesDataStore.data.combine(serverRepository.currentUserFlow) { appPrefs, user ->
UserPreferences(appPrefs, user?.appPreferences)
}
val flow = preferencesDataStore.data.map { UserPreferences(it) }
suspend fun getCurrent(): UserPreferences = flow.first()
suspend fun getCurrent(): UserPreferences =
serverRepository.currentUserDto!!.configuration.let { userConfig ->
val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
UserPreferences(
appPrefs,
)
}
}

View file

@ -12,9 +12,9 @@ import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withContext
@ -56,7 +56,7 @@ class UserSwitchListener
user.uiLanguage?.let { LocaleListCompat.forLanguageTags(it) }
?: LocaleListCompat.getEmptyLocaleList()
Timber.i("Switching locale to %s", localeList)
withContext(WholphinDispatchers.Main) {
withContext(Dispatchers.Main) {
AppCompatDelegate.setApplicationLocales(localeList)
}

View file

@ -1,13 +1,18 @@
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
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.WholphinDispatchers
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.RememberTabManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -15,7 +20,9 @@ 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
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.android.androidDevice
@ -42,21 +49,21 @@ annotation class AuthOkHttpClient
annotation class StandardOkHttpClient
/**
* A [CoroutineScope] with [WholphinDispatchers.IO]
* A [CoroutineScope] with [Dispatchers.IO]
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoCoroutineScope
/**
* A [CoroutineScope] with [WholphinDispatchers.Default]
* A [CoroutineScope] with [Dispatchers.Default]
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class DefaultCoroutineScope
/**
* [WholphinDispatchers.IO]
* [Dispatchers.IO]
*
* @see IoCoroutineScope
*/
@ -65,7 +72,7 @@ annotation class DefaultCoroutineScope
annotation class IoDispatcher
/**
* [WholphinDispatchers.Default]
* [Dispatchers.Default]
*
* @see DefaultCoroutineScope
*/
@ -153,10 +160,55 @@ object AppModule {
@Singleton
fun apiClient(jellyfin: Jellyfin) = jellyfin.createApi()
/**
* Implementation of [RememberTabManager] which remembers by server, user, & item
*/
@Provides
@Singleton
fun rememberTabManager(
serverRepository: ServerRepository,
appPreference: DataStore<AppPreferences>,
@IoCoroutineScope scope: CoroutineScope,
) = object : RememberTabManager {
fun key(itemId: String): String =
serverRepository.current.value.let {
"${it?.server?.id}_${it?.user?.id}_$itemId"
}
override fun getRememberedTab(
preferences: UserPreferences,
itemId: String,
defaultTab: Int,
): Int {
if (preferences.appPreferences.interfacePreferences.rememberSelectedTab) {
return preferences.appPreferences.interfacePreferences
.getRememberedTabsOrDefault(key(itemId), defaultTab)
} else {
return defaultTab
}
}
override fun saveRememberedTab(
preferences: UserPreferences,
itemId: String,
tabIndex: Int,
) {
if (preferences.appPreferences.interfacePreferences.rememberSelectedTab) {
scope.launch(ExceptionHandler()) {
appPreference.updateData {
it.updateInterfacePreferences {
putRememberedTabs(key(itemId), tabIndex)
}
}
}
}
}
}
@Provides
@Singleton
@IoDispatcher
fun ioDispatcher(): CoroutineDispatcher = WholphinDispatchers.IO
fun ioDispatcher(): CoroutineDispatcher = Dispatchers.IO
@Provides
@Singleton
@ -168,7 +220,7 @@ object AppModule {
@Provides
@Singleton
@DefaultDispatcher
fun defaultDispatcher(): CoroutineDispatcher = WholphinDispatchers.Default
fun defaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
@Provides
@Singleton

View file

@ -16,7 +16,6 @@ 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.RememberedTabDao
import com.github.damontecres.wholphin.data.SeerrServerDao
import com.github.damontecres.wholphin.data.ServerPreferencesDao
import com.github.damontecres.wholphin.preferences.AppPreferences
@ -44,7 +43,7 @@ object DatabaseModule {
).addMigrations(Migrations.Migrate2to3)
// .setQueryCallback({ sqlQuery, args ->
// Timber.v("sqlQuery=$sqlQuery, args=$args")
// }, WholphinDispatchers.IO.asExecutor())
// }, Dispatchers.IO.asExecutor())
.build()
@Provides
@ -75,10 +74,6 @@ object DatabaseModule {
@Singleton
fun playbackEffectDao(db: AppDatabase): PlaybackEffectDao = db.playbackEffectDao()
@Provides
@Singleton
fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao()
@Provides
@Singleton
fun userPreferencesDataStore(

View file

@ -24,7 +24,6 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.IntentService
import com.github.damontecres.wholphin.services.LatestNextUpService
import com.github.damontecres.wholphin.ui.SlimItemFields
import dagger.assisted.Assisted
@ -227,27 +226,27 @@ class TvProviderWorker
setIntent(
Intent(context, MainActivity::class.java)
.putExtra(IntentService.INTENT_ITEM_ID, item.id.toString())
.putExtra(IntentService.INTENT_ITEM_TYPE, item.type.serialName)
.putExtra(MainActivity.INTENT_ITEM_ID, item.id.toString())
.putExtra(MainActivity.INTENT_ITEM_TYPE, item.type.serialName)
.apply {
if (item.type == BaseItemKind.EPISODE) {
putExtra(
IntentService.INTENT_SERIES_ID,
MainActivity.INTENT_SERIES_ID,
dto.seriesId?.toString(),
)
putExtra(
IntentService.INTENT_SEASON_ID,
MainActivity.INTENT_SEASON_ID,
dto.seasonId?.toString(),
)
dto.parentIndexNumber?.let {
putExtra(
IntentService.INTENT_SEASON_NUMBER,
MainActivity.INTENT_SEASON_NUMBER,
it,
)
}
dto.indexNumber?.let {
putExtra(
IntentService.INTENT_EPISODE_NUMBER,
MainActivity.INTENT_EPISODE_NUMBER,
it,
)
}
@ -265,7 +264,6 @@ class TvProviderWorker
.getLatestMedia(
GetLatestMediaRequest(
fields = SlimItemFields,
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE),
imageTypeLimit = 1,
parentId = null,
groupItems = true,
@ -364,27 +362,27 @@ class TvProviderWorker
setIntent(
Intent(context, MainActivity::class.java)
.putExtra(IntentService.INTENT_ITEM_ID, item.id.toString())
.putExtra(IntentService.INTENT_ITEM_TYPE, item.type.serialName)
.putExtra(MainActivity.INTENT_ITEM_ID, item.id.toString())
.putExtra(MainActivity.INTENT_ITEM_TYPE, item.type.serialName)
.apply {
if (item.type == BaseItemKind.EPISODE) {
putExtra(
IntentService.INTENT_SERIES_ID,
MainActivity.INTENT_SERIES_ID,
dto.seriesId?.toString(),
)
putExtra(
IntentService.INTENT_SEASON_ID,
MainActivity.INTENT_SEASON_ID,
dto.seasonId?.toString(),
)
dto.parentIndexNumber?.let {
putExtra(
IntentService.INTENT_SEASON_NUMBER,
MainActivity.INTENT_SEASON_NUMBER,
it,
)
}
dto.indexNumber?.let {
putExtra(
IntentService.INTENT_EPISODE_NUMBER,
MainActivity.INTENT_EPISODE_NUMBER,
it,
)
}

View file

@ -9,7 +9,6 @@ import coil3.disk.DiskCache
import coil3.disk.directory
import coil3.memory.MemoryCache
import coil3.network.CacheStrategy
import coil3.network.ConnectivityChecker
import coil3.network.NetworkRequest
import coil3.network.NetworkResponse
import coil3.network.cachecontrol.CacheControlCacheStrategy
@ -96,8 +95,6 @@ fun CoilConfig(
OkHttpNetworkFetcherFactory(
cacheStrategy = { WholphinCacheStrategy(CacheControlCacheStrategy()) },
callFactory = { client },
// Disable connectivity checker which may be unreliable for VPNs and/or some versions of Android
connectivityChecker = { ConnectivityChecker { true } },
),
)
}.build()

View file

@ -33,9 +33,9 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.data.RowColumnSaver
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.WholphinDispatchers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
@ -74,8 +74,6 @@ inline fun <T> List<T>.indexOfFirstOrNull(predicate: (T) -> Boolean): Int? {
return if (index >= 0) index else null
}
fun Iterable<CharSequence?>.joinNotBlank(sep: String): String? = filter { it.isNotNullOrBlank() }.letNotEmpty { it.joinToString(sep) }
/**
* Try to call [FocusRequester.requestFocus], but catch & log the exception if something is not configured properly
*/
@ -244,7 +242,7 @@ val BaseItemDto.timeRemaining: Duration?
get() =
userData?.playbackPositionTicks?.let {
if (it > 0) {
runTimeTicks?.minus(it)?.ticks?.takeIf { it > Duration.ZERO }
runTimeTicks?.minus(it)?.ticks
} else {
null
}
@ -361,34 +359,34 @@ suspend fun showToast(
context: Context,
text: CharSequence,
duration: Int,
) = withContext(WholphinDispatchers.Main) {
) = withContext(Dispatchers.Main) {
Toast.makeText(context, text, duration).show()
}
suspend fun showToast(
context: Context,
text: CharSequence,
) = withContext(WholphinDispatchers.Main) {
) = withContext(Dispatchers.Main) {
Toast.makeText(context, text, Toast.LENGTH_LONG).show()
}
/**
* Launches a coroutine with [WholphinDispatchers.IO] plus the provided [CoroutineContext] defaulting to using [ExceptionHandler]
* Launches a coroutine with [Dispatchers.IO] plus the provided [CoroutineContext] defaulting to using [ExceptionHandler]
*/
fun CoroutineScope.launchIO(
context: CoroutineContext = ExceptionHandler(),
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit,
): Job = launch(context = WholphinDispatchers.IO + context, start = start, block = block)
): Job = launch(context = Dispatchers.IO + context, start = start, block = block)
/**
* Launches a coroutine with [WholphinDispatchers.Default] plus the provided [CoroutineContext] defaulting to using [ExceptionHandler]
* 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 = WholphinDispatchers.Default + context, start = start, block = block)
): Job = launch(context = Dispatchers.Default + context, start = start, block = block)
/**
* Converts a UUID to the format used server-side (ie without hyphens).
@ -412,7 +410,7 @@ fun logTab(
ACRA.errorReporter.putCustomData("tabInfo", info)
}
suspend fun <T> onMain(block: suspend CoroutineScope.() -> T) = withContext(WholphinDispatchers.Main, block)
suspend fun <T> onMain(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main, block)
fun Modifier.dimAndBlur(enabled: Boolean) =
this.ifElse(
@ -427,9 +425,6 @@ fun Response<BaseItemDtoQueryResult>.toBaseItems(
useSeriesForPrimary: Boolean,
) = this.content.items.map { BaseItem.from(it, api, useSeriesForPrimary) }
fun Response<BaseItemDtoQueryResult>.toBaseItems(useSeriesForPrimary: Boolean = false) =
this.content.items.map { BaseItem(it, useSeriesForPrimary = useSeriesForPrimary) }
/**
* Check if this, coalescing nulls to zero, is greater than that
*/
@ -451,15 +446,3 @@ fun <T> Flow<T>.collectLatestIn(
* Easy way to combine two flows into a [Pair]
*/
fun <T1, T2> Flow<T1>.combinePair(flow: Flow<T2>): Flow<Pair<T1, T2>> = combine(flow) { t1, t2 -> Pair(t1, t2) }
/**
* Easy way to combine three flows into a [Triple]
*/
fun <T1, T2, T3> Flow<T1>.combineTriple(
flow: Flow<T2>,
flow2: Flow<T3>,
): Flow<Triple<T1, T2, T3>> =
combine(flow) { t1, t2 -> Pair(t1, t2) }
.combine(flow2) { (t1, t2), t3 ->
Triple(t1, t2, t3)
}

View file

@ -1,8 +1,5 @@
package com.github.damontecres.wholphin.ui
import android.content.Context
import android.content.res.Resources
import android.text.format.DateFormat
import androidx.annotation.StringRes
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.ui.text.AnnotatedString
@ -15,36 +12,19 @@ import org.jellyfin.sdk.model.api.MediaSegmentType
import timber.log.Timber
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
import java.time.format.FormatStyle
import java.util.Date
import java.util.Locale
import kotlin.time.Duration
/**
* Format the time-of-day of [time] honouring the device's 12/24-hour setting and the current
* locale. [context] should be an Activity/composition context (e.g. `LocalContext.current`) so an
* in-app language switch (`AppCompatDelegate.setApplicationLocales`) is reflected; the application
* context may carry a stale locale.
*/
fun formatTime(
context: Context,
time: LocalDateTime,
): String = formatTime(context, time.toLocalTime())
private var timeFormatter: DateTimeFormatter =
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(Locale.getDefault())
fun formatTime(
context: Context,
time: LocalTime,
): String {
val instant =
time
.atDate(LocalDate.now())
.atZone(ZoneId.systemDefault())
.toInstant()
return DateFormat.getTimeFormat(context).format(Date.from(instant))
fun getTimeFormatter(): DateTimeFormatter {
if (timeFormatter.locale != Locale.getDefault()) {
timeFormatter = timeFormatter.withLocale(Locale.getDefault())
}
return timeFormatter
}
private var dateFormatter: DateTimeFormatter =
@ -79,74 +59,36 @@ fun toLocalDate(date: String?): LocalDate? =
}
}
// Falls back to the default format if the application is not initialized (e.g. unit tests)
private fun formatStringWithFallback(
@StringRes resId: Int,
defaultFormat: String,
vararg args: Any,
): String {
val resources =
try {
WholphinApplication.instance.resources
} catch (_: UninitializedPropertyAccessException) {
null
}
return resources?.getString(resId, *args)
?: String.format(Locale.getDefault(), defaultFormat, *args)
}
/**
* Format season & episode numbers using localized string resources.
* Returns null if [season] or [episode] is missing.
*/
fun formatSeasonEpisode(
season: Int?,
episode: Int?,
episodeEnd: Int? = null,
padded: Boolean = false,
): String? {
if (season == null || episode == null) return null
return if (episodeEnd != null) {
if (padded) {
formatStringWithFallback(R.string.season_episode_number_padded_range, "S%1\$02dE%2\$02d-E%3\$02d", season, episode, episodeEnd)
} else {
formatStringWithFallback(R.string.season_episode_number_range, "S%1\$d E%2\$d-E%3\$d", season, episode, episodeEnd)
}
} else if (padded) {
formatStringWithFallback(R.string.season_episode_number_padded, "S%1\$02dE%2\$02d", season, episode)
} else {
formatStringWithFallback(R.string.season_episode_number, "S%1\$d E%2\$d", season, episode)
}
}
/** Compact episode-only label, e.g. `E3`. */
fun formatEpisodeNumber(episode: Int): String = formatStringWithFallback(R.string.episode_number_short, "E%1\$d", episode)
/** Fallback season title when the library has no season name, e.g. `Season 2`. */
fun formatSeasonNumber(season: Int): String = formatStringWithFallback(R.string.season_number, "Season %1\$d", season)
/**
* If the item has season & episode info, format as localized `S# E#`
* If the item has season & episode info, format as `S# E#`
*/
val BaseItemDto.seasonEpisode: String?
get() =
formatSeasonEpisode(
parentIndexNumber,
indexNumber,
indexNumberEnd,
)
if (parentIndexNumber != null && indexNumber != null && indexNumberEnd != null) {
"S$parentIndexNumber E$indexNumber-E$indexNumberEnd"
} else if (parentIndexNumber != null && indexNumber != null) {
"S$parentIndexNumber E$indexNumber"
} else {
null
}
/**
* If the item has season & episode info, format padded as localized `S##E##`
* If the item has season & episode info, format padded as `S## E##`
*/
val BaseItemDto.seasonEpisodePadded: String?
get() =
formatSeasonEpisode(
parentIndexNumber,
indexNumber,
indexNumberEnd,
padded = true,
)
if (parentIndexNumber != null && indexNumber != null) {
val season = parentIndexNumber?.toString()?.padStart(2, '0')
val episode = indexNumber?.toString()?.padStart(2, '0')
val endEpisode = indexNumberEnd?.toString()?.padStart(2, '0')
if (endEpisode != null) {
"S${season}E$episode-E$endEpisode"
} else {
"S${season}E$episode"
}
} else {
null
}
val BaseItemDto.seriesProductionYears: String?
get() =
@ -258,64 +200,44 @@ fun listToDotString(
}
}
@get:StringRes
val BaseItemKind.titleStringRes: Int
get() = formatTypeName(this)
@StringRes
fun formatTypeName(type: BaseItemKind): Int =
when (type) {
BaseItemKind.MOVIE -> R.string.movies_title
BaseItemKind.SERIES -> R.string.tv_shows_title
BaseItemKind.MOVIE -> R.string.movies
BaseItemKind.SERIES -> R.string.tv_shows
BaseItemKind.EPISODE -> R.string.episodes
BaseItemKind.VIDEO -> R.string.videos
BaseItemKind.PLAYLIST -> R.string.playlists
BaseItemKind.PERSON -> R.string.people_title
BaseItemKind.PERSON -> R.string.people
BaseItemKind.BOX_SET -> R.string.collections
BaseItemKind.AUDIO -> R.string.songs
BaseItemKind.AUDIO -> TODO()
BaseItemKind.CHANNEL -> R.string.channels
BaseItemKind.GENRE -> R.string.genres
BaseItemKind.LIVE_TV_CHANNEL -> R.string.channels
BaseItemKind.MUSIC_ALBUM -> R.string.albums
BaseItemKind.MUSIC_ARTIST -> R.string.artists
BaseItemKind.MUSIC_GENRE -> R.string.genres
BaseItemKind.MUSIC_VIDEO -> R.string.music_videos
BaseItemKind.MUSIC_ALBUM -> TODO()
BaseItemKind.MUSIC_ARTIST -> TODO()
BaseItemKind.MUSIC_GENRE -> TODO()
BaseItemKind.MUSIC_VIDEO -> TODO()
BaseItemKind.PHOTO -> R.string.photos
BaseItemKind.PHOTO_ALBUM -> R.string.photo_albums
BaseItemKind.PROGRAM -> R.string.programs
BaseItemKind.RECORDING -> R.string.recordings
BaseItemKind.PHOTO_ALBUM -> TODO()
BaseItemKind.PROGRAM -> TODO()
BaseItemKind.RECORDING -> TODO()
BaseItemKind.SEASON -> R.string.tv_seasons
BaseItemKind.STUDIO -> R.string.studios
BaseItemKind.TRAILER -> R.string.trailers_title
BaseItemKind.TRAILER -> R.string.trailers
BaseItemKind.TV_CHANNEL -> R.string.channels
BaseItemKind.TV_PROGRAM -> R.string.programs
BaseItemKind.USER_ROOT_FOLDER -> R.string.folders_title
BaseItemKind.USER_VIEW -> R.string.user_views
BaseItemKind.YEAR -> R.string.year
BaseItemKind.AGGREGATE_FOLDER -> R.string.folders_title
BaseItemKind.AUDIO_BOOK -> R.string.audio_books
BaseItemKind.BASE_PLUGIN_FOLDER -> R.string.folders_title
BaseItemKind.BOOK -> R.string.books
BaseItemKind.CHANNEL_FOLDER_ITEM -> R.string.folders_title
BaseItemKind.COLLECTION_FOLDER -> R.string.library
BaseItemKind.FOLDER -> R.string.folders_title
BaseItemKind.MANUAL_PLAYLISTS_FOLDER -> R.string.playlists
BaseItemKind.LIVE_TV_PROGRAM -> R.string.programs
BaseItemKind.PLAYLISTS_FOLDER -> R.string.playlists
}
/**
* Format a [Duration] into a localized "Xh Ym Zs" string using string resources.
*/
fun Resources.formatDuration(duration: Duration): String =
duration.toComponents { hours, minutes, seconds, _ ->
when {
hours > 0 && minutes > 0 && seconds > 0 -> getString(R.string.duration_hours_minutes_seconds, hours.toInt(), minutes, seconds)
hours > 0 && minutes > 0 -> getString(R.string.duration_hours_minutes, hours.toInt(), minutes)
hours > 0 && seconds > 0 -> getString(R.string.duration_hours_seconds, hours.toInt(), seconds)
hours > 0 -> getString(R.string.duration_hours, hours.toInt())
minutes > 0 && seconds > 0 -> getString(R.string.duration_minutes_seconds, minutes, seconds)
minutes > 0 -> getString(R.string.duration_minutes, minutes)
else -> getString(R.string.duration_seconds, seconds)
}
BaseItemKind.TV_PROGRAM -> TODO()
BaseItemKind.USER_ROOT_FOLDER -> TODO()
BaseItemKind.USER_VIEW -> TODO()
BaseItemKind.YEAR -> TODO()
BaseItemKind.AGGREGATE_FOLDER -> TODO()
BaseItemKind.AUDIO_BOOK -> TODO()
BaseItemKind.BASE_PLUGIN_FOLDER -> TODO()
BaseItemKind.BOOK -> TODO()
BaseItemKind.CHANNEL_FOLDER_ITEM -> TODO()
BaseItemKind.COLLECTION_FOLDER -> TODO()
BaseItemKind.FOLDER -> TODO()
BaseItemKind.MANUAL_PLAYLISTS_FOLDER -> TODO()
BaseItemKind.LIVE_TV_PROGRAM -> TODO()
BaseItemKind.PLAYLISTS_FOLDER -> TODO()
}

View file

@ -38,25 +38,27 @@ object AppColors {
val Purple = Color(147, 51, 234)
val Green = Color(74, 222, 128)
val Yellow = Color(234, 179, 8)
val Red = Color(204, 38, 38)
}
}
const val DEFAULT_PAGE_SIZE = 100
/**
* The [ItemFields] to fetch for detailed paged and playback
* The default [ItemFields] to fetch for most queries
*/
val DetailItemFields =
val DefaultItemFields =
listOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.SEASON_USER_DATA,
ItemFields.CHILD_COUNT,
ItemFields.OVERVIEW,
ItemFields.TRICKPLAY,
ItemFields.SORT_NAME,
ItemFields.CHAPTERS,
ItemFields.MEDIA_SOURCES,
ItemFields.MEDIA_SOURCE_COUNT,
ItemFields.PARENT_ID,
ItemFields.CAN_DELETE,
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
)
/**
@ -64,37 +66,27 @@ val DetailItemFields =
*/
val SlimItemFields =
listOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.SEASON_USER_DATA,
ItemFields.CHILD_COUNT,
ItemFields.OVERVIEW,
ItemFields.SORT_NAME,
ItemFields.MEDIA_SOURCE_COUNT,
ItemFields.CAN_DELETE,
)
/**
* ItemFields for displaying items in rows such as in a [com.github.damontecres.wholphin.ui.cards.ItemRow] with [com.github.damontecres.wholphin.ui.cards.SeasonCard]
*/
val ItemRowFields = SlimItemFields + ItemFields.PRIMARY_IMAGE_ASPECT_RATIO
val HomeItemFields =
listOf(
ItemFields.OVERVIEW,
ItemFields.PARENT_ID,
ItemFields.CAN_DELETE,
)
val PhotoItemFields =
DetailItemFields +
DefaultItemFields +
listOf(
ItemFields.WIDTH,
ItemFields.HEIGHT,
)
val ProgramItemFields = DetailItemFields + listOf(ItemFields.CHANNEL_INFO)
object Cards {
const val HEIGHT_2X3_DP = 172
val height2x3 = HEIGHT_2X3_DP.dp
const val HEIGHT_EPISODE = 128
const val HEIGHT_LIVE_TV = 96
val heightEpisode = HEIGHT_EPISODE.dp
val playedPercentHeight = 6.dp
val serverUserCircle = height2x3 * .75f

View file

@ -1,5 +1,6 @@
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
@ -73,7 +74,7 @@ fun BannerCard(
val imageUrlService = LocalImageUrlService.current
val density = LocalDensity.current
val fillHeight =
remember(cardHeight, density) {
remember(cardHeight) {
if (cardHeight.isSpecified) {
with(density) {
cardHeight.roundToPx()
@ -218,11 +219,13 @@ fun BannerCardWithTitle(
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) 0.dp else 8.dp)
val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
val width = cardHeight * aspectRationToUse
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(spaceBetween),
modifier = modifier.width(width),
) {
BannerCard(
@ -242,7 +245,13 @@ fun BannerCardWithTitle(
imageContentScale = imageContentScale,
useSeriesForPrimary = useSeriesForPrimary,
)
SlidingCardText(focused) {
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
.padding(bottom = spaceBelow)
.fillMaxWidth(),
) {
Text(
text = title ?: "",
style = MaterialTheme.typography.bodyLarge,

View file

@ -5,62 +5,39 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.times
import androidx.tv.material3.Card
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.data.model.Chapter
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.formatDuration
import com.github.damontecres.wholphin.ui.roundSeconds
import org.jellyfin.sdk.model.api.ImageType
import kotlin.time.Duration
/**
* Card for a [com.github.damontecres.wholphin.data.model.Chapter]
*/
@Composable
fun ChapterCard(
chapter: Chapter,
name: String?,
position: Duration,
imageUrl: String?,
onClick: () -> Unit,
aspectRatio: Float,
modifier: Modifier = Modifier,
cardHeight: Dp = 120.dp,
onLongClick: (() -> Unit)? = null,
interactionSource: MutableInteractionSource? = null,
) {
val density = LocalDensity.current
val imageUrlService = LocalImageUrlService.current
val imageUrl =
remember(chapter, cardHeight, density) {
imageUrlService.getItemImageUrl(
itemId = chapter.itemId,
imageType = ImageType.CHAPTER,
tag = chapter.tag,
imageIndex = chapter.index,
fillHeight = with(density) { cardHeight.roundToPx() },
)
}
var width by remember { mutableStateOf(AspectRatios.WIDE * cardHeight) }
Card(
modifier = modifier.height(cardHeight),
modifier = modifier.size(cardHeight * aspectRatio, cardHeight),
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
@ -71,36 +48,22 @@ fun ChapterCard(
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(),
onSuccess = {
width =
with(density) {
it.painter.intrinsicSize.width
.toDp()
}
},
)
Box(
modifier =
Modifier
.width(width)
.align(Alignment.BottomStart)
.fillMaxWidth()
.background(AppColors.TransparentBlack50),
) {
Column(modifier = Modifier.padding(4.dp)) {
chapter.name?.let {
name?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
)
}
val resources = LocalResources.current
val positionText =
remember(chapter.position) { resources.formatDuration(chapter.position.roundSeconds) }
Text(
text = positionText,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Normal,
text = position.roundSeconds.toString(),
)
}
}

View file

@ -21,6 +21,7 @@ import com.github.damontecres.wholphin.data.model.Chapter
@Composable
fun ChapterRow(
chapters: List<Chapter>,
aspectRatio: Float,
onClick: (Chapter) -> Unit,
modifier: Modifier = Modifier,
onLongClick: ((Chapter) -> Unit)? = null,
@ -46,8 +47,11 @@ fun ChapterRow(
) {
itemsIndexed(chapters) { index, item ->
ChapterCard(
chapter = item,
name = item.name,
position = item.position,
imageUrl = item.imageUrl,
onClick = { onClick(item) },
aspectRatio = aspectRatio,
modifier = Modifier,
onLongClick = onLongClick?.let { { it(item) } },
)

View file

@ -59,6 +59,8 @@ fun DiscoverItemCard(
width: Dp = Cards.height2x3 * AspectRatios.TALL,
) {
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
@ -76,7 +78,7 @@ fun DiscoverItemCard(
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(spaceBetween),
modifier = modifier.size(width, Dp.Unspecified),
) {
Card(
@ -128,16 +130,7 @@ fun DiscoverItemCard(
AvailableIndicator(Modifier.align(Alignment.TopEnd))
}
SeerrAvailability.BLOCKLISTED -> {
// TODO handle block listed
// BlocklistedIndicator(Modifier.align(Alignment.TopEnd))
}
SeerrAvailability.UNKNOWN,
SeerrAvailability.DELETED,
null,
-> {
}
else -> {}
}
if (showOverlay) {
val color =
@ -178,7 +171,13 @@ fun DiscoverItemCard(
}
}
}
SlidingCardText(focused) {
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
.padding(bottom = spaceBelow)
.fillMaxWidth(),
) {
Text(
text = item?.title ?: "",
maxLines = 1,
@ -285,31 +284,6 @@ fun PartiallyAvailableIndicator(modifier: Modifier = Modifier) {
}
}
@Composable
fun BlocklistedIndicator(modifier: Modifier = Modifier) {
Box(
modifier =
modifier
.padding(4.dp)
.border(
width = .5.dp,
color = Color.White,
shape = CircleShape,
).background(
color = AppColors.Discover.Red.copy(alpha = .85f),
shape = CircleShape,
).size(16.dp),
) {
Text(
text = stringResource(R.string.fa_xmark),
fontFamily = FontAwesome,
fontSize = 10.sp,
color = Color.White,
modifier = Modifier.align(Alignment.Center),
)
}
}
@PreviewTvSpec
@Composable
private fun Preview() {
@ -318,7 +292,6 @@ private fun Preview() {
PendingIndicator()
AvailableIndicator()
PartiallyAvailableIndicator()
BlocklistedIndicator()
}
}
}

View file

@ -0,0 +1,162 @@
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.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.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.AppColors
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.seasonEpisode
import kotlinx.coroutines.delay
@Composable
fun EpisodeCard(
item: BaseItem?,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
imageHeight: Dp = Dp.Unspecified,
imageWidth: Dp = Dp.Unspecified,
showImageOverlay: Boolean = false,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val dto = item?.data
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 aspectRatio = item?.aspectRatio?.coerceAtLeast(AspectRatios.MIN) ?: AspectRatios.MIN
val width = imageHeight * aspectRatio
val height = imageWidth * (1f / aspectRatio)
val density = LocalDensity.current
val imageWidthPx = remember(imageWidth) { with(density) { imageWidth.roundToPx() } }
Column(
verticalArrangement = Arrangement.spacedBy(spaceBetween),
modifier = modifier.size(width, height),
) {
Card(
modifier =
Modifier
.size(imageWidth, imageHeight)
.aspectRatio(aspectRatio),
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
colors =
CardDefaults.colors(
containerColor = Color.Transparent,
),
) {
Box(
modifier =
Modifier
.fillMaxSize(),
) {
ItemCardImage(
item = item,
name = item?.name,
showOverlay = showImageOverlay,
favorite = dto?.userData?.isFavorite ?: false,
watched = dto?.userData?.played ?: false,
unwatchedCount = dto?.userData?.unplayedItemCount ?: -1,
watchedPercent = dto?.userData?.playedPercentage,
numberOfVersions = dto?.mediaSourceCount ?: 0,
useFallbackText = false,
fillWidth = imageWidthPx,
modifier =
Modifier
.fillMaxSize(),
)
Box(
modifier =
Modifier
.align(Alignment.TopEnd)
.background(
AppColors.TransparentBlack50,
shape = RoundedCornerShape(8.dp),
),
) {
Text(
text = dto?.seasonEpisode ?: "",
modifier = Modifier.padding(4.dp),
)
}
}
}
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
.padding(bottom = spaceBelow)
.fillMaxWidth(),
) {
Text(
text = dto?.seriesName ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.enableMarquee(focusedAfterDelay),
)
Text(
text = item?.name ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Normal,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.enableMarquee(focusedAfterDelay),
)
}
}
}

View file

@ -17,7 +17,7 @@ fun ExtrasRow(
modifier: Modifier = Modifier,
) {
ItemRow(
title = stringResource(R.string.extras_title),
title = stringResource(R.string.extras),
items = extras,
onClickItem = onClickItem,
onLongClickItem = onLongClickItem,
@ -38,7 +38,7 @@ fun ExtrasRow(
unplayedItemCount = -1,
playedPercentage = item?.playedPercentage ?: 0.0,
numberOfVersions = -1,
aspectRatio = item?.aspectRatio ?: AspectRatios.WIDE,
aspectRatio = AspectRatios.FOUR_THREE, // TODO
)
},
modifier = modifier,

View file

@ -30,6 +30,7 @@ import coil3.request.crossfade
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.components.Genre
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.setup.rememberIdColor
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import java.util.UUID

View file

@ -52,6 +52,8 @@ fun GridCard(
) {
val dto = item?.data
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
@ -69,7 +71,7 @@ fun GridCard(
}
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(spaceBetween),
modifier = modifier,
) {
Card(
@ -105,7 +107,13 @@ fun GridCard(
)
}
AnimatedVisibility(showTitle) {
SlidingCardText(focused) {
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
.padding(bottom = spaceBelow)
.fillMaxWidth(),
) {
Text(
text = item?.title ?: "",
maxLines = 1,

View file

@ -39,7 +39,6 @@ import com.github.damontecres.wholphin.ui.AppColors
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.gt
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.logCoilError
import org.jellyfin.sdk.model.api.ImageType
@ -68,16 +67,14 @@ fun ItemCardImage(
) {
val imageUrlService = LocalImageUrlService.current
val imageUrl =
remember(item, imageType, fillWidth, fillHeight) {
if (item != null && (fillWidth.gt(0) || fillHeight.gt(0))) {
remember(item) {
item?.let {
imageUrlService.getItemImageUrl(
item,
imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
)
} else {
null
}
}
ItemCardImage(
@ -265,7 +262,7 @@ fun ItemCardImageOverlay(
}
}
if (watchedPercent != null && watchedPercent > 0 && watchedPercent < 100) {
watchedPercent?.let { percent ->
Box(
modifier =
Modifier
@ -274,7 +271,7 @@ fun ItemCardImageOverlay(
MaterialTheme.colorScheme.tertiary,
).clip(RectangleShape)
.height(Cards.playedPercentHeight)
.fillMaxWidth((watchedPercent / 100.0).toFloat()),
.fillMaxWidth((percent / 100.0).toFloat()),
)
}
}

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.ui.cards
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
@ -10,6 +11,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
@ -24,9 +26,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.layout
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.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Border
@ -95,6 +99,10 @@ fun PersonCard(
focusedAfterDelay = false
}
// Do not use `by` here, this way we are Defer reads and recompositions to only when modifier calculates
val spaceBetweenState = animateDpAsState(if (focused) 12.dp else 4.dp, label = "spaceBetween")
val spaceBelowState = animateDpAsState(if (focused) 4.dp else 12.dp, label = "spaceBelow")
Column(
verticalArrangement = Arrangement.spacedBy(4.dp), // Fixed base spacing
modifier = modifier,
@ -162,7 +170,21 @@ fun PersonCard(
.clip(CircleShape),
)
}
SlidingCardText(focused) {
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
// Optimization: move animation reads to layout/draw phase
.offset {
IntOffset(0, (spaceBetweenState.value - 4.dp).roundToPx())
}.layout { measurable, constraints ->
val paddingPx = spaceBelowState.value.roundToPx()
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height + paddingPx) {
placeable.placeRelative(0, 0)
}
}.fillMaxWidth(),
) {
Text(
text = name ?: "",
maxLines = 1,

View file

@ -33,7 +33,7 @@ fun PersonRow(
people: List<Person>,
onClick: (Person) -> Unit,
modifier: Modifier = Modifier,
@StringRes title: Int = R.string.people_title,
@StringRes title: Int = R.string.people,
onLongClick: ((Int, Person) -> Unit)? = null,
) {
val firstFocus = remember { FocusRequester() }
@ -84,7 +84,7 @@ fun DiscoverPersonRow(
people: List<DiscoverItem>,
onClick: (DiscoverItem) -> Unit,
modifier: Modifier = Modifier,
@StringRes title: Int = R.string.people_title,
@StringRes title: Int = R.string.people,
onLongClick: ((Int, DiscoverItem) -> Unit)? = null,
) {
val firstFocus = remember { FocusRequester() }

View file

@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
@ -23,8 +24,8 @@ 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.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isSpecified
import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
import androidx.tv.material3.MaterialTheme
@ -37,21 +38,51 @@ import kotlinx.coroutines.delay
import org.jellyfin.sdk.model.api.ImageType
/**
* A Card for a TV Show Season, but can generally show most items
* A Card for a TV Show Season, but can generically show most items
*/
@Composable
fun SeasonCard(
item: BaseItem?,
onClick: () -> Unit,
onLongClick: () -> Unit,
imageHeight: Dp,
modifier: Modifier = Modifier,
imageHeight: Dp = Dp.Unspecified,
imageWidth: Dp = Dp.Unspecified,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
showImageOverlay: Boolean = false,
aspectRatio: Float = item?.aspectRatio ?: AspectRatios.TALL,
) {
val imageUrl = rememberImageUrl(item, imageHeight, imageWidth)
val imageUrlService = LocalImageUrlService.current
val density = LocalDensity.current
val imageUrl =
remember(item, imageHeight, imageWidth, density) {
if (item != null) {
val fillHeight =
if (imageHeight != Dp.Unspecified) {
with(density) {
imageHeight.roundToPx()
}
} else {
null
}
val fillWidth =
if (imageWidth != Dp.Unspecified) {
with(density) {
imageWidth.roundToPx()
}
} else {
null
}
imageUrlService.getItemImageUrl(
item,
ImageType.PRIMARY,
fillWidth = fillWidth,
fillHeight = fillHeight,
)
} else {
null
}
}
SeasonCard(
title = item?.title,
subtitle = item?.subtitle,
@ -73,47 +104,6 @@ fun SeasonCard(
)
}
@Composable
fun rememberImageUrl(
item: BaseItem?,
imageHeight: Dp,
imageWidth: Dp = Dp.Unspecified,
): String? {
val density = LocalDensity.current
val imageUrlService = LocalImageUrlService.current
return remember(item, imageHeight, imageWidth, density) {
if (item != null) {
val fillHeight =
if (imageHeight.isSpecified) {
with(density) {
imageHeight.roundToPx()
}
} else {
null
}
val fillWidth =
if (imageWidth.isSpecified) {
with(density) {
imageWidth.roundToPx()
}
} else {
null
}
imageUrlService.getItemImageUrl(
item,
ImageType.PRIMARY,
fillWidth = fillWidth,
fillHeight = fillHeight,
)
} else {
null
}
}
}
private val spaceBetweenFocused = 4.dp
private val spaceBetweenUnfocused = 12.dp
/**
* A Card for a TV Show Season, but can generically show most items
*/
@ -139,11 +129,8 @@ fun SeasonCard(
) {
val focused by interactionSource.collectIsFocusedAsState()
// Do not use `by` here, this way we are Defer reads and recompositions to only when modifier calculates
val spaceBetween =
animateDpAsState(
if (focused) spaceBetweenFocused else spaceBetweenUnfocused,
label = "spaceBetween",
)
val spaceBetween = animateDpAsState(if (focused) 12.dp else 4.dp, label = "spaceBetween")
val spaceBelow = animateDpAsState(if (focused) 4.dp else 12.dp, label = "spaceBelow")
val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
@ -192,12 +179,14 @@ fun SeasonCard(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
.layout { measurable, constraints ->
val topPaddingPx = (spaceBetweenUnfocused - spaceBetween.value).roundToPx()
val bottomPaddingPx = spaceBetween.value.roundToPx()
// Optimization: move animation reads to layout/draw phase
.offset {
IntOffset(0, (spaceBetween.value - 4.dp).roundToPx())
}.layout { measurable, constraints ->
val paddingPx = spaceBelow.value.roundToPx()
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height + bottomPaddingPx + topPaddingPx) {
placeable.placeRelative(0, topPaddingPx)
layout(placeable.width, placeable.height + paddingPx) {
placeable.placeRelative(0, 0)
}
}.fillMaxWidth(),
) {

View file

@ -1,46 +0,0 @@
package com.github.damontecres.wholphin.ui.cards
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layout
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Slides the [content] up/down as the card is [focused] or not
*
* This is used for the card's title, moving it down when focused as the card itself scales up
*/
@Composable
fun SlidingCardText(
focused: Boolean,
modifier: Modifier = Modifier,
spaceBetweenFocused: Dp = 4.dp,
spaceBetweenUnfocused: Dp = 12.dp,
content: @Composable ColumnScope.() -> Unit,
) {
val spaceBetween =
animateDpAsState(
if (focused) spaceBetweenFocused else spaceBetweenUnfocused,
label = "spaceBetween",
)
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
modifier
.layout { measurable, constraints ->
val topPaddingPx = (spaceBetweenUnfocused - spaceBetween.value).roundToPx()
val bottomPaddingPx = spaceBetween.value.roundToPx()
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height + bottomPaddingPx + topPaddingPx) {
placeable.placeRelative(0, topPaddingPx)
}
}.fillMaxWidth(),
content = content,
)
}

View file

@ -32,7 +32,9 @@ import coil3.request.ImageRequest
import coil3.request.crossfade
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.components.Genre
import com.github.damontecres.wholphin.ui.components.Studio
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.setup.rememberIdColor
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import java.util.UUID

View file

@ -53,6 +53,8 @@ fun ViewMoreCard(
showTitle: Boolean = 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)
var focusedAfterDelay by remember { mutableStateOf(false) }
val hideOverlayDelay = 500L
@ -77,7 +79,7 @@ fun ViewMoreCard(
size.height.takeIf { it.isSpecified } ?: (size.height * (1f / aspectRatio.ratio))
}
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(spaceBetween),
modifier = modifier,
) {
Card(
@ -93,20 +95,24 @@ fun ViewMoreCard(
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
),
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize(),
) {
Box {
Icon(
imageVector = Icons.Default.ArrowForward,
tint = MaterialTheme.colorScheme.onSurface,
contentDescription = "View more",
modifier = Modifier.fillMaxSize(.66f),
modifier = Modifier.fillMaxSize(),
)
}
}
if (showTitle) {
SlidingCardText(focused) {
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier =
Modifier
.width(width)
.padding(bottom = spaceBelow),
) {
Text(
text = stringResource(R.string.view_more),
maxLines = 1,

View file

@ -45,8 +45,6 @@ fun WatchedIconBackground(): Color =
AppThemeColors.GREEN,
AppThemeColors.ORANGE,
AppThemeColors.BOLD_BLUE,
AppThemeColors.RED,
AppThemeColors.BROWN,
-> MaterialTheme.colorScheme.border.copy(alpha = 1f)
AppThemeColors.OLED_BLACK -> MaterialTheme.colorScheme.secondaryContainer
@ -62,8 +60,6 @@ fun WatchedIconColor(): Color =
AppThemeColors.ORANGE,
AppThemeColors.BOLD_BLUE,
AppThemeColors.OLED_BLACK,
AppThemeColors.RED,
AppThemeColors.BROWN,
-> Color.White // MaterialTheme.colorScheme.onSurface
}

View file

@ -7,10 +7,7 @@ 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.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
@ -18,9 +15,6 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus
import kotlinx.coroutines.delay
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
@Composable
fun CircularProgress(modifier: Modifier = Modifier) {
@ -64,41 +58,3 @@ fun LoadingPage(
)
}
}
/**
* Fill the space and take focus, showing a loading indicator after the specified delay
*/
@Composable
fun DelayedLoadingPage(
modifier: Modifier = Modifier,
focusEnabled: Boolean = true,
delay: Duration = 300.milliseconds,
) {
val focusRequester = remember { FocusRequester() }
if (focusEnabled) {
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
}
var show by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(delay)
show = true
}
Box(
contentAlignment = Alignment.Center,
modifier =
modifier
.fillMaxSize()
.focusRequester(focusRequester)
.focusable(focusEnabled),
) {
if (show) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.border,
modifier =
Modifier
.align(Alignment.Center)
.size(48.dp),
)
}
}
}

View file

@ -9,7 +9,6 @@ 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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@ -22,7 +21,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
@ -42,7 +40,6 @@ import com.github.damontecres.wholphin.ui.cards.GridCard
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.CardGrid
import com.github.damontecres.wholphin.ui.detail.GridItemDetails
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.main.HomePageHeader
import com.github.damontecres.wholphin.ui.playback.scale
import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
@ -115,7 +112,7 @@ fun CollectionFolderGrid(
columns = viewOptions.columns,
spacing = viewOptions.spacing.dp,
bringIntoViewSpec =
remember(viewOptions, density) {
remember(viewOptions) {
val spacingPx = with(density) { viewOptions.spacing.dp.toPx() }
if (viewOptions.showDetails) {
ScrollToTopBringIntoViewSpec(spacingPx)
@ -207,7 +204,7 @@ val CollectionType.baseItemKinds: List<BaseItemKind>
}
CollectionType.TVSHOWS -> {
listOf(BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE)
listOf(BaseItemKind.SERIES)
}
CollectionType.HOMEVIDEOS -> {
@ -222,10 +219,6 @@ val CollectionType.baseItemKinds: List<BaseItemKind>
)
}
CollectionType.MUSICVIDEOS -> {
listOf(BaseItemKind.MUSIC_VIDEO)
}
CollectionType.BOXSETS -> {
listOf(BaseItemKind.BOX_SET)
}
@ -234,33 +227,7 @@ val CollectionType.baseItemKinds: List<BaseItemKind>
listOf(BaseItemKind.PLAYLIST)
}
CollectionType.PHOTOS -> {
listOf(BaseItemKind.PHOTO, BaseItemKind.PHOTO_ALBUM)
}
CollectionType.LIVETV -> {
listOf(
BaseItemKind.TV_CHANNEL,
BaseItemKind.LIVE_TV_CHANNEL,
BaseItemKind.PROGRAM,
BaseItemKind.TV_PROGRAM,
BaseItemKind.LIVE_TV_PROGRAM,
)
}
CollectionType.TRAILERS -> {
listOf(BaseItemKind.TRAILER)
}
CollectionType.BOOKS -> {
listOf(BaseItemKind.BOOK)
}
CollectionType.FOLDERS -> {
listOf(BaseItemKind.FOLDER)
}
CollectionType.UNKNOWN -> {
else -> {
listOf()
}
}
@ -289,7 +256,6 @@ data class GridClickActions(
@Composable
fun CollectionFolderHeader(
listIsNotEmpty: Boolean,
showHeader: Boolean,
showTitle: Boolean,
playEnabled: Boolean,
@ -300,14 +266,10 @@ fun CollectionFolderHeader(
getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>,
onClickShowViewOptions: () -> Unit,
onClickPlayAll: (Boolean) -> Unit,
onClickRandom: () -> Unit,
modifier: Modifier = Modifier,
currentFilter: GetItemsFilter = GetItemsFilter(),
filterOptions: List<ItemFilterBy<*>> = listOf(),
onFilterChange: (GetItemsFilter) -> Unit = {},
onShowFilterDropdown: ((Boolean) -> Unit)? = null,
filterButtonFocusRequester: FocusRequester = remember { FocusRequester() },
randomButtonFocusRequester: FocusRequester = remember { FocusRequester() },
) {
AnimatedVisibility(
showHeader,
@ -315,7 +277,6 @@ fun CollectionFolderHeader(
exit = shrinkVertically(),
modifier = modifier,
) {
val focusRequester = remember { FocusRequester() }
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
@ -325,15 +286,15 @@ fun CollectionFolderHeader(
}
val endPadding =
16.dp + if (sortAndDirection.sort == ItemSortBy.SORT_NAME) 24.dp else 0.dp
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.padding(start = 16.dp, end = endPadding)
.fillMaxWidth(),
) {
if (sortOptions.isNotEmpty() || filterOptions.isNotEmpty()) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
@ -344,7 +305,7 @@ fun CollectionFolderHeader(
sortOptions = sortOptions,
current = sortAndDirection,
onSortChange = onSortChange,
modifier = Modifier.focusRequester(focusRequester),
modifier = Modifier,
)
}
if (filterOptions.isNotEmpty()) {
@ -353,56 +314,33 @@ fun CollectionFolderHeader(
current = currentFilter,
onFilterChange = onFilterChange,
getPossibleValues = getPossibleFilterValues,
modifier =
Modifier
.focusRequester(filterButtonFocusRequester)
.ifElse(
sortOptions.isEmpty(),
Modifier.focusRequester(focusRequester),
),
onShow = onShowFilterDropdown,
modifier = Modifier,
)
}
ExpandableFaButton(
title = R.string.view_options,
iconStringRes = R.string.fa_sliders,
onClick = onClickShowViewOptions,
modifier =
Modifier.ifElse(
sortOptions.isEmpty() && filterOptions.isEmpty(),
Modifier.focusRequester(focusRequester),
),
modifier = Modifier,
)
}
Spacer(Modifier.weight(1f))
}
if (playEnabled) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier,
) {
ExpandableFaButton(
title = R.string.sort_by_random,
iconStringRes = R.string.fa_dice,
onClick = onClickRandom,
modifier = Modifier.focusRequester(randomButtonFocusRequester),
enabled = listIsNotEmpty,
)
if (playEnabled) {
ExpandablePlayButton(
title = R.string.play,
resume = Duration.ZERO,
icon = Icons.Default.PlayArrow,
onClick = { onClickPlayAll.invoke(false) },
enabled = listIsNotEmpty,
)
ExpandableFaButton(
title = R.string.shuffle,
iconStringRes = R.string.fa_shuffle,
onClick = { onClickPlayAll.invoke(true) },
enabled = listIsNotEmpty,
)
}
}

View file

@ -28,7 +28,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -39,7 +38,6 @@ import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.tv.material3.ListItemDefaults
@ -60,7 +58,6 @@ import com.github.damontecres.wholphin.ui.cards.WatchedIcon
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.AlphabetButtons
import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.formatDuration
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.rememberInt
@ -100,17 +97,15 @@ fun CollectionFolderList(
val positionFocusRequester = remember { FocusRequester() }
val showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME
var showHeader by rememberSaveable { mutableStateOf(true) }
var showHeader by remember { mutableStateOf(true) }
fun jumpTo(newPosition: Int) {
scope.launch {
if (newPosition >= 0) {
position = newPosition
listState.animateScrollToItem(newPosition)
positionFocusRequester.tryRequestFocus()
}
}
}
BackHandler(contentHashFocus && position > 0) {
jumpTo(0)
@ -362,7 +357,7 @@ fun ListItemGeneric(
?.runTimeTicks
?.ticks
?.roundMinutes
?.let { LocalResources.current.formatDuration(it) } ?: "",
?.toString() ?: "",
)
},
scale = scale,
@ -424,7 +419,7 @@ fun ListItemMovie(
?.runTimeTicks
?.ticks
?.roundMinutes
?.let { LocalResources.current.formatDuration(it) } ?: "",
?.toString() ?: "",
)
if (item?.played == true) {
WatchedIcon(padding = 0.dp)
@ -551,7 +546,7 @@ fun ListItemAlbum(
.runTimeTicks
?.ticks
?.roundMinutes
?.let { LocalResources.current.formatDuration(it) } ?: "",
?.toString() ?: "",
)
}
} else {

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