Merge branch 'main' into develop/ac3-test

This commit is contained in:
Damontecres 2026-04-19 15:44:50 -04:00
commit ace714da7b
No known key found for this signature in database
291 changed files with 17616 additions and 4635 deletions

View file

@ -1,82 +0,0 @@
name: "Native build"
description: "Performs native builds"
inputs:
cache:
description: "Whether to use the cache or not"
required: true
runs:
using: "composite"
steps:
- name: Load ffmpeg module cache
id: cache_ffmpeg_module
if: ${{ inputs.cache }}
uses: actions/cache/restore@v5
with:
path: |
app/libs
key: ${{ runner.os }}-ffmpeg-${{ hashFiles('**/libs.versions.toml', '**/scripts/ffmpeg/build_ffmpeg_decoder.sh') }}
- name: Load libmpv module cache
id: cache_libmpv_module
if: ${{ inputs.cache }}
uses: actions/cache/restore@v5
with:
path: |
app/src/main/libs
key: ${{ runner.os }}-libmpv-${{ hashFiles('**/scripts/mpv/*.sh', '**/scripts/mpv/include/*', '**/scripts/mpv/scripts/*', 'app/src/main/jni/*') }}
- name: Install dependencies
if: steps.cache_ffmpeg_module.outputs.cache-hit != 'true' || steps.cache_libmpv_module.outputs.cache-hit != 'true'
shell: bash
run: |
python -m pip install --upgrade pip
pip install jsonschema jinja2
sudo apt update
sudo apt install -y build-essential autoconf pkg-config libtool ninja-build unzip wget meson nasm
# ffmpeg
- name: Build ffmpeg decoder
id: ffmpeg-decoder
if: steps.cache_ffmpeg_module.outputs.cache-hit != 'true'
shell: bash
run: |
cd scripts/ffmpeg
./build_ffmpeg_decoder.sh "${{ env.ANDROID_SDK_ROOT }}/ndk/${{ env.NDK_VERSION }}"
- name: Save ffmpeg module cache
id: cache_ffmpeg_module_save
uses: actions/cache/save@v5
with:
path: |
app/libs
key: ${{ steps.cache_ffmpeg_module.outputs.cache-primary-key }}
# libmpv
- name: Get libmpv dependencies
if: steps.cache_libmpv_module.outputs.cache-hit != 'true'
shell: bash
run: |
cd scripts/mpv
./get_dependencies.sh
- name: Build libmpv dependencies
if: steps.cache_libmpv_module.outputs.cache-hit != 'true'
shell: bash
run: |
cd scripts/mpv
./buildall.sh --clean --arch arm64 mpv
./buildall.sh mpv
cd ../..
env PREFIX32="$(realpath scripts/mpv/prefix/armv7l)" PREFIX64="$(realpath scripts/mpv/prefix/arm64)" ndk-build -C app/src/main -j
- name: Setup jniLibs
shell: bash
run: |
cd app/src/main
mkdir -p jniLibs
cp -r libs/* jniLibs
#ln -s libs jniLibs
- name: Save libmpv module cache
id: cache_libmpv_module_save
uses: actions/cache/save@v5
with:
path: |
app/src/main/libs
key: ${{ steps.cache_libmpv_module.outputs.cache-primary-key }}

View file

@ -21,7 +21,7 @@ runs:
echo "BUILD_TOOLS_VERSION=36.0.0" >> $GITHUB_ENV echo "BUILD_TOOLS_VERSION=36.0.0" >> $GITHUB_ENV
echo "NDK_VERSION=29.0.14206865" >> $GITHUB_ENV echo "NDK_VERSION=29.0.14206865" >> $GITHUB_ENV
- name: Setup Android SDK - name: Setup Android SDK
uses: android-actions/setup-android@v3 uses: android-actions/setup-android@v4
with: with:
packages: "tools 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 - name: Add NDK to path

View file

@ -30,10 +30,6 @@ jobs:
fetch-depth: 0 # Need the tags to build fetch-depth: 0 # Need the tags to build
- name: Setup - name: Setup
uses: ./.github/actions/setup uses: ./.github/actions/setup
- name: Native build
uses: ./.github/actions/native-build
with:
cache: true
- name: Get version names - name: Get version names
run: | run: |
@ -55,8 +51,11 @@ jobs:
KEY_PASSWORD: "${{ secrets.KEY_PASSWORD }}" KEY_PASSWORD: "${{ secrets.KEY_PASSWORD }}"
KEY_STORE_PASSWORD: "${{ secrets.KEY_STORE_PASSWORD }}" KEY_STORE_PASSWORD: "${{ secrets.KEY_STORE_PASSWORD }}"
SIGNING_KEY: "${{ secrets.SIGNING_KEY }}" SIGNING_KEY: "${{ secrets.SIGNING_KEY }}"
ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}"
ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}"
run: | run: |
./gradlew clean assembleRelease assembleDebug --no-daemon ./gradlew clean assembleDefaultRelease assembleDefaultDebug --no-daemon
- name: Verify signatures - name: Verify signatures
run: | run: |
echo "Verify APK signatures" echo "Verify APK signatures"
@ -68,12 +67,10 @@ jobs:
find app/build/outputs/apk -name '*.apk' -print0 | find app/build/outputs/apk -name '*.apk' -print0 |
while IFS= read -r -d '' line; do while IFS= read -r -d '' line; do
# Wholphin-release-0.2.9-62-g5c12e03-16-arm64-v8a.apk => Wholphin-arm64-v8a.apk # Wholphin-release-0.2.9-62-g5c12e03-16-arm64-v8a.apk => Wholphin-arm64-v8a.apk
short_name="$(echo $line | sed -E 's/-[0-9]+\.[0-9]+\.[0-9]+-[0-9]+-g[a-fA-F0-9]+-[0-9]+//')" short_name="$(echo $line | sed -E 's/-[0-9]+\.[0-9]+\.[0-9]+-[0-9]+-g[a-fA-F0-9]+-[0-9]+//' | sed -E 's/-default//')"
echo "$line => $short_name" echo "$line => $short_name"
cp "$line" "$short_name" cp "$line" "$short_name"
done done
apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//')
echo "apks=$apks" >> "$GITHUB_OUTPUT"
- name: Checksums - name: Checksums
run: | run: |
echo "SHA256 checksums:" echo "SHA256 checksums:"
@ -82,6 +79,8 @@ jobs:
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
shopt -s globstar
gh release delete "${{ env.TAG_NAME }}" --cleanup-tag -y || true gh release delete "${{ env.TAG_NAME }}" --cleanup-tag -y || true
@ -98,4 +97,4 @@ jobs:
gh release create "${{ env.TAG_NAME }}" \ gh release create "${{ env.TAG_NAME }}" \
--latest=false --prerelease --title "${{ env.VERSION_NAME }}" --target "${{ env.BRANCH_NAME }}" -F ci_release_note.txt \ --latest=false --prerelease --title "${{ env.VERSION_NAME }}" --target "${{ env.BRANCH_NAME }}" -F ci_release_note.txt \
"app/build/outputs/apk/**/*.apk" app/build/outputs/apk/**/*.apk

View file

@ -36,26 +36,9 @@ jobs:
fetch-depth: 0 # Need the tags to build fetch-depth: 0 # Need the tags to build
- name: Setup - name: Setup
uses: ./.github/actions/setup uses: ./.github/actions/setup
- name: Native build
uses: ./.github/actions/native-build
with:
cache: true
- name: Build app - name: Build app
id: buildapp id: buildapp
run: | run: |
./gradlew clean assembleDebug testDebugUnitTest --no-daemon ./gradlew clean assembleDefaultDebug testDefaultDebugUnitTest --no-daemon
apks=$(find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | tr '\0' ',' | sed 's/,$//') apks=$(find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | tr '\0' ',' | sed 's/,$//')
echo "apks=$apks" >> "$GITHUB_OUTPUT" echo "apks=$apks" >> "$GITHUB_OUTPUT"
test-patch:
runs-on: ubuntu-latest
needs: pre-commit
steps:
- name: Checkout the code
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Test applying patch
run: |
git apply app/src/patches/play_store.patch
git diff

View file

@ -26,10 +26,6 @@ jobs:
fetch-depth: 0 # Need the tags to build fetch-depth: 0 # Need the tags to build
- name: Setup - name: Setup
uses: ./.github/actions/setup uses: ./.github/actions/setup
- name: Native build
uses: ./.github/actions/native-build
with:
cache: false
- name: Build app - name: Build app
id: buildapp id: buildapp
env: env:
@ -37,21 +33,11 @@ jobs:
KEY_PASSWORD: "${{ secrets.KEY_PASSWORD }}" KEY_PASSWORD: "${{ secrets.KEY_PASSWORD }}"
KEY_STORE_PASSWORD: "${{ secrets.KEY_STORE_PASSWORD }}" KEY_STORE_PASSWORD: "${{ secrets.KEY_STORE_PASSWORD }}"
SIGNING_KEY: "${{ secrets.SIGNING_KEY }}" SIGNING_KEY: "${{ secrets.SIGNING_KEY }}"
ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}"
ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}"
run: | run: |
./gradlew clean assembleRelease --no-daemon ./gradlew clean assembleDefaultRelease bundleAppstoreRelease bundleFiretvRelease --no-daemon
- name: Build app
id: buildaab
env:
KEY_ALIAS: "${{ secrets.KEY_ALIAS }}"
KEY_PASSWORD: "${{ secrets.KEY_PASSWORD }}"
KEY_STORE_PASSWORD: "${{ secrets.KEY_STORE_PASSWORD }}"
SIGNING_KEY: "${{ secrets.SIGNING_KEY }}"
run: |
git apply app/src/patches/play_store.patch
./gradlew bundleRelease --no-daemon
aab=$(find app/build/outputs -name '*.aab')
echo "aab=$aab" >> "$GITHUB_OUTPUT"
- name: Verify signatures - name: Verify signatures
run: | run: |
echo "Verify APK signatures" echo "Verify APK signatures"
@ -62,13 +48,11 @@ jobs:
run: | run: |
find app/build/outputs/apk -name '*.apk' -print0 | find app/build/outputs/apk -name '*.apk' -print0 |
while IFS= read -r -d '' line; do while IFS= read -r -d '' line; do
# Wholphin-release-0.2.9-62-g5c12e03-16-arm64-v8a.apk => Wholphin-arm64-v8a.apk # Wholphin-default-release-0.2.9-62-g5c12e03-16-arm64-v8a.apk => Wholphin-arm64-v8a.apk
short_name="$(echo $line | sed -E 's/-release-[0-9]+\.[0-9]+\.[0-9]+-[0-9]+-g[a-fA-F0-9]+-[0-9]+//')" short_name="$(echo $line | sed -E 's/-default-release-[0-9]+\.[0-9]+\.[0-9]+-[0-9]+-g[a-fA-F0-9]+-[0-9]+//')"
echo "$line => $short_name" echo "$line => $short_name"
cp "$line" "$short_name" cp "$line" "$short_name"
done done
apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//')
echo "apks=$apks" >> "$GITHUB_OUTPUT"
- name: Checksums - name: Checksums
run: | run: |
echo "SHA256 checksums:" echo "SHA256 checksums:"
@ -84,6 +68,8 @@ jobs:
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
shopt -s globstar
gh release create "${{ env.TAG_NAME }}" \ gh release create "${{ env.TAG_NAME }}" \
--latest --title "${{ env.TAG_NAME }}" --verify-tag -n "" --draft \ --latest --title "${{ env.TAG_NAME }}" --verify-tag -n "" --draft \
"app/build/outputs/apk/**/*.apk" app/build/outputs/apk/**/*.apk

View file

@ -14,6 +14,7 @@ The app uses:
* [Room](https://developer.android.com/training/data-storage/room) & [DataStore](https://developer.android.com/topic/libraries/architecture/datastore) for local data storage * [Room](https://developer.android.com/training/data-storage/room) & [DataStore](https://developer.android.com/topic/libraries/architecture/datastore) for local data storage
* [Hilt](https://developer.android.com/training/dependency-injection/hilt-android) for dependency injection * [Hilt](https://developer.android.com/training/dependency-injection/hilt-android) for dependency injection
* [Media3/ExoPlayer](https://developer.android.com/media/media3/exoplayer) for media playback * [Media3/ExoPlayer](https://developer.android.com/media/media3/exoplayer) for media playback
* [MPV/libmpv](https://github.com/mpv-player/mpv) for media playback
* [Coil](https://coil-kt.github.io/coil/) for image loading * [Coil](https://coil-kt.github.io/coil/) for image loading
* [OkHttp](https://square.github.io/okhttp/) for HTTP requests * [OkHttp](https://square.github.io/okhttp/) for HTTP requests
@ -42,21 +43,13 @@ Code is split into several packages:
- `ui` - User interface code and ViewModels - `ui` - User interface code and ViewModels
- `util` - Utility classes and functions - `util` - Utility classes and functions
### Native components ## Extensions
#### FFmpeg decoder module Wholphin uses several native components for extra playback compatibility. This includes Media3 ffmpeg/av1 decoders and `libmpv`. These extensions are not required to build the app, but without them some functionality will not work.
Wholphin ships with [media3 ffmpeg decoder module](https://github.com/androidx/media/blob/release/libraries/decoder_ffmpeg/README.md). If you want to include these in a local build, see the [instructions here](https://github.com/damontecres/wholphin-extensions?tab=readme-ov-file#usage) for configuring the repository.
It is not required to build the extension in order to build the app locally. 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.
You can build the module on MacOS or Linux with the [`build_ffmpeg_decoder.sh`](./scripts/ffmpeg/build_ffmpeg_decoder.sh) script.
#### MPV player backend
Wholphin has a playback engine that uses [`libmpv`](https://github.com/mpv-player/mpv). The app uses JNI code from [`mpv-android`](https://github.com/mpv-android/mpv-android) and has an implementation of `androidx.media3.common.Player` to swap out for `ExoPlayer`.
See the [build scripts](scripts/mpv/) for details on building this component.
### App settings ### App settings

View file

@ -33,7 +33,8 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin
- Customize the home page to see the content you are interested in - Customize the home page to see the content you are interested in
- Use poster or thumb images, show/hide titles, add/remove/re-order different types of rows! - Use poster or thumb images, show/hide titles, add/remove/re-order different types of rows!
- A navigation drawer for quick access to libraries, favorites, search, and settings from almost anywhere in the app - A navigation drawer for quick access to libraries, favorites, search, and settings from almost anywhere in the app
- Integration with [Jellyseerr](https://github.com/seerr-team/seerr) to discover new movies and TV shows - Integration with [Jellyseerr/Seerr](https://github.com/seerr-team/seerr) to discover new movies and TV shows
- Note: only available when installed from [GitHub](https://github.com/damontecres/Wholphin/releases/latest) or the [Play store](https://play.google.com/store/apps/details?id=com.github.damontecres.wholphin)
- Option to combine Continue Watching & Next Up rows - Option to combine Continue Watching & Next Up rows
- Show Movie/TV Show titles when browsing libraries - Show Movie/TV Show titles when browsing libraries
- Play theme music, if available - Play theme music, if available
@ -58,7 +59,6 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin
- Trickplay support - Trickplay support
- Subtly show playback position along the bottom of the screen while seeking w/ D-Pad - Subtly show playback position along the bottom of the screen while seeking w/ D-Pad
### Roadmap ### Roadmap
See [here for the roadmap](https://github.com/damontecres/Wholphin/wiki#roadmap) See [here for the roadmap](https://github.com/damontecres/Wholphin/wiki#roadmap)

View file

@ -1,3 +1,4 @@
import com.android.build.api.dsl.ProductFlavor
import com.google.protobuf.gradle.id import com.google.protobuf.gradle.id
import com.mikepenz.aboutlibraries.plugin.DuplicateMode import com.mikepenz.aboutlibraries.plugin.DuplicateMode
import com.mikepenz.aboutlibraries.plugin.DuplicateRule import com.mikepenz.aboutlibraries.plugin.DuplicateRule
@ -21,6 +22,8 @@ val isCI = if (System.getenv("CI") != null) System.getenv("CI").toBoolean() else
val shouldSign = isCI && System.getenv("KEY_ALIAS") != null val shouldSign = isCI && System.getenv("KEY_ALIAS") != null
val ffmpegModuleExists = project.file("libs/lib-decoder-ffmpeg-release.aar").exists() val ffmpegModuleExists = project.file("libs/lib-decoder-ffmpeg-release.aar").exists()
val av1ModuleExists = project.file("libs/lib-decoder-av1-release.aar").exists() val 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")
val gitTags = val gitTags =
providers providers
@ -45,6 +48,8 @@ android {
versionCode = gitTags.trim().lines().size versionCode = gitTags.trim().lines().size
versionName = gitDescribe.trim().removePrefix("v").ifBlank { "0.0.0" } versionName = gitDescribe.trim().removePrefix("v").ifBlank { "0.0.0" }
testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner" testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner"
buildConfigField("long", "BUILD_TIME", System.currentTimeMillis().toString())
} }
signingConfigs { signingConfigs {
@ -106,6 +111,38 @@ android {
} }
} }
} }
flavorDimensions += "version"
productFlavors {
val featureLeanback = "leanback"
val featureUpdate = "UPDATING_ENABLED"
val featureDiscover = "DISCOVER_ENABLED"
fun ProductFlavor.setFeatureFlag(
name: String,
enabled: Boolean,
) {
this.buildConfigField("boolean", name, "Boolean.parseBoolean(\"${enabled}\")")
}
create("default") {
dimension = "version"
isDefault = true
manifestPlaceholders += mapOf(featureLeanback to false)
setFeatureFlag(featureUpdate, true)
setFeatureFlag(featureDiscover, true)
}
create("appstore") {
dimension = "version"
manifestPlaceholders += mapOf(featureLeanback to true)
setFeatureFlag(featureUpdate, false)
setFeatureFlag(featureDiscover, true)
}
create("firetv") {
dimension = "version"
manifestPlaceholders += mapOf(featureLeanback to true)
setFeatureFlag(featureUpdate, false)
setFeatureFlag(featureDiscover, false)
}
}
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11
@ -134,6 +171,12 @@ android {
isUniversalApk = true isUniversalApk = true
} }
} }
packaging {
jniLibs {
// Work around because libass-android & wholphin-mpv both (incorrectly) package libc++_shared.so
pickFirsts += "lib/*/libc++_shared.so"
}
}
sourceSets { sourceSets {
getByName("main") { getByName("main") {
@ -146,6 +189,10 @@ android {
isIncludeAndroidResources = true isIncludeAndroidResources = true
} }
} }
lint {
disable.add("MissingTranslation")
}
} }
protobuf { protobuf {
@ -219,6 +266,7 @@ dependencies {
implementation(libs.androidx.lifecycle.livedata.ktx) implementation(libs.androidx.lifecycle.livedata.ktx)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.datastore) implementation(libs.androidx.datastore)
implementation(libs.androidx.datastore.preferences)
implementation(libs.protobuf.kotlin.lite) implementation(libs.protobuf.kotlin.lite)
implementation(libs.androidx.tvprovider) implementation(libs.androidx.tvprovider)
implementation(libs.androidx.work.runtime.ktx) implementation(libs.androidx.work.runtime.ktx)
@ -231,6 +279,7 @@ dependencies {
implementation(libs.androidx.media3.exoplayer.dash) implementation(libs.androidx.media3.exoplayer.dash)
implementation(libs.androidx.media3.ui) implementation(libs.androidx.media3.ui)
implementation(libs.androidx.media3.ui.compose) implementation(libs.androidx.media3.ui.compose)
implementation(libs.ass.media)
implementation(libs.coil.core) implementation(libs.coil.core)
implementation(libs.coil.compose) implementation(libs.coil.compose)
@ -287,11 +336,33 @@ dependencies {
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest) debugImplementation(libs.androidx.compose.ui.test.manifest)
coreLibraryDesugaring(libs.desugar.jdk.libs) coreLibraryDesugaring(libs.desugar.jdk.libs)
if (ffmpegModuleExists || isCI) {
if (ffmpegModuleExists) {
logger.info("Using local ffmpeg decoder")
implementation(files("libs/lib-decoder-ffmpeg-release.aar")) implementation(files("libs/lib-decoder-ffmpeg-release.aar"))
} 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 || isCI) { if (av1ModuleExists) {
logger.info("Using local av1 decoder")
implementation(files("libs/lib-decoder-av1-release.aar")) implementation(files("libs/lib-decoder-av1-release.aar"))
} 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) {
logger.info("Using local libMPV build")
implementation(files("libs/wholphin-mpv-release.aar"))
} else if (extensionsRepoActive) {
logger.info("Using prebuilt libMPV")
implementation(libs.wholphin.extensions.mpv)
} else {
logger.warn("libMPV was NOT found")
} }
testImplementation(libs.mockk.android) testImplementation(libs.mockk.android)

View file

@ -9,7 +9,9 @@ import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey import androidx.compose.ui.test.pressKey
import androidx.navigation3.runtime.NavBackStack
import com.github.damontecres.wholphin.MainContent import com.github.damontecres.wholphin.MainContent
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.ScreensaverState import com.github.damontecres.wholphin.services.ScreensaverState
import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupDestination
@ -43,16 +45,17 @@ class InstrumentedBasicUiTests {
@OptIn(ExperimentalTestApi::class) @OptIn(ExperimentalTestApi::class)
@Test @Test
fun myTest() { fun myTest() {
val navigationManager = NavigationManager()
navigationManager.backStack = NavBackStack(Destination.Home())
// Start the app // Start the app
composeTestRule.setContent { composeTestRule.setContent {
WholphinTheme { WholphinTheme {
MainContent( MainContent(
backStack = mutableListOf(SetupDestination.ServerList), backStack = mutableListOf(SetupDestination.ServerList),
navigationManager = mockk(relaxed = true), navigationManager = navigationManager,
appPreferences = mockk(relaxed = true), appPreferences = mockk(relaxed = true),
backdropService = mockk(relaxed = true), backdropService = mockk(relaxed = true),
screensaverService = screensaverService, screensaverService = screensaverService,
requestedDestination = Destination.Home(),
modifier = Modifier, modifier = Modifier,
) )
} }

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" tools:node="remove" />
</manifest>

View file

@ -1,94 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:installLocation="auto">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<uses-feature
android:name="android.software.leanback"
android:required="false" />
<uses-feature
android:name="android.hardware.microphone"
android:required="false" />
<!-- Required for Android 11+ to query voice recognition availability -->
<queries>
<intent>
<action android:name="android.speech.action.RECOGNIZE_SPEECH" />
</intent>
</queries>
<application
android:allowBackup="true"
android:banner="@mipmap/ic_banner"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Wholphin"
android:name=".WholphinApplication"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".test.TestActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:node="remove" />
<!-- <service-->
<!-- android:name=".WholphinDreamService"-->
<!-- android:exported="true"-->
<!-- android:label="@string/app_name"-->
<!-- android:permission="android.permission.BIND_DREAM_SERVICE">-->
<!-- <intent-filter>-->
<!-- <action android:name="android.service.dreams.DreamService" />-->
<!-- <category android:name="android.intent.category.DEFAULT" />-->
<!-- </intent-filter>-->
<!-- </service>-->
</application>
</manifest>

View file

@ -20,7 +20,7 @@
android:required="false" /> android:required="false" />
<uses-feature <uses-feature
android:name="android.software.leanback" android:name="android.software.leanback"
android:required="false" /> android:required="${leanback}" />
<uses-feature <uses-feature
android:name="android.hardware.microphone" android:name="android.hardware.microphone"
android:required="false" /> android:required="false" />
@ -30,6 +30,10 @@
<intent> <intent>
<action android:name="android.speech.action.RECOGNIZE_SPEECH" /> <action android:name="android.speech.action.RECOGNIZE_SPEECH" />
</intent> </intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="video/*" />
</intent>
</queries> </queries>
<application <application

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin package com.github.damontecres.wholphin
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.res.Configuration import android.content.res.Configuration
import android.os.Bundle import android.os.Bundle
@ -24,15 +25,18 @@ import androidx.datastore.core.DataStore
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.navigation3.runtime.NavBackStack
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.services.AppUpgradeHandler import com.github.damontecres.wholphin.services.AppUpgradeHandler
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.DatePlayedInvalidationService import com.github.damontecres.wholphin.services.DatePlayedInvalidationService
import com.github.damontecres.wholphin.services.DeviceProfileService import com.github.damontecres.wholphin.services.DeviceProfileService
import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.LatestNextUpSchedulerService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver
import com.github.damontecres.wholphin.services.RefreshRateService import com.github.damontecres.wholphin.services.RefreshRateService
@ -50,14 +54,15 @@ import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
import com.github.damontecres.wholphin.util.DebugLogTree import com.github.damontecres.wholphin.util.DebugLogTree
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
@ -66,7 +71,9 @@ import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.jellyfin.sdk.model.serializer.toUUIDOrNull
@ -111,6 +118,9 @@ class MainActivity : AppCompatActivity() {
@Inject @Inject
lateinit var suggestionsSchedulerService: SuggestionsSchedulerService lateinit var suggestionsSchedulerService: SuggestionsSchedulerService
@Inject
lateinit var latestNextUpSchedulerService: LatestNextUpSchedulerService
@Inject @Inject
lateinit var backdropService: BackdropService lateinit var backdropService: BackdropService
@ -127,6 +137,11 @@ class MainActivity : AppCompatActivity() {
private var signInAuto = true private var signInAuto = true
private val json =
Json {
classDiscriminator = "_type"
}
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -134,6 +149,26 @@ class MainActivity : AppCompatActivity() {
Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}") Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}")
lifecycle.addObserver(playbackLifecycleObserver) lifecycle.addObserver(playbackLifecycleObserver)
val backStackStr = savedInstanceState?.getString(KEY_BACK_STACK)
if (backStackStr != null) {
Timber.d("Restoring back stack")
var backStack = json.decodeFromString<List<Destination>>(backStackStr)
if (!savedInstanceState.getBoolean(KEY_EXTERNAL_PLAYER)) {
val lastDest = backStack.lastOrNull()
if (lastDest is Destination.Playback ||
lastDest is Destination.PlaybackList ||
lastDest is Destination.Slideshow
) {
Timber.v("Restoring back stack with playback")
backStack = backStack.toMutableList().apply { removeAt(lastIndex) }
}
}
navigationManager.backStack = NavBackStack(*backStack.toTypedArray())
} else {
val startDestination = intent?.let(::extractDestination) ?: Destination.Home()
navigationManager.backStack = NavBackStack(startDestination)
}
viewModel.serverRepository.currentUser.observe(this) { user -> viewModel.serverRepository.currentUser.observe(this) { user ->
if (user?.hasPin == true) { if (user?.hasPin == true) {
window?.setFlags( window?.setFlags(
@ -206,17 +241,12 @@ class MainActivity : AppCompatActivity() {
appThemeColors = appPreferences.interfacePreferences.appThemeColors, appThemeColors = appPreferences.interfacePreferences.appThemeColors,
) { ) {
ProvideLocalClock { ProvideLocalClock {
val requestedDestination =
remember(intent) {
intent?.let(::extractDestination) ?: Destination.Home()
}
MainContent( MainContent(
backStack = setupNavigationManager.backStack, backStack = setupNavigationManager.backStack,
navigationManager = navigationManager, navigationManager = navigationManager,
appPreferences = appPreferences, appPreferences = appPreferences,
backdropService = backdropService, backdropService = backdropService,
screensaverService = screensaverService, screensaverService = screensaverService,
requestedDestination = requestedDestination,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} }
@ -287,6 +317,11 @@ class MainActivity : AppCompatActivity() {
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
Timber.d("onSaveInstanceState") Timber.d("onSaveInstanceState")
val str = json.encodeToString(navigationManager.backStack.toList())
outState.putString(KEY_BACK_STACK, str)
val playerBackend =
runBlocking { userPreferencesDataStore.data.firstOrNull() }?.playbackPreferences?.playerBackend
outState.putBoolean(KEY_EXTERNAL_PLAYER, playerBackend == PlayerBackend.EXTERNAL_PLAYER)
} }
override fun onRestoreInstanceState(savedInstanceState: Bundle) { override fun onRestoreInstanceState(savedInstanceState: Bundle) {
@ -362,6 +397,9 @@ class MainActivity : AppCompatActivity() {
const val INTENT_SEASON_NUMBER = "seaNum" const val INTENT_SEASON_NUMBER = "seaNum"
const val INTENT_SEASON_ID = "seaId" const val INTENT_SEASON_ID = "seaId"
private const val KEY_BACK_STACK = "backStack"
private const val KEY_EXTERNAL_PLAYER = "extPlayer"
lateinit var instance: MainActivity lateinit var instance: MainActivity
private set private set
} }
@ -371,6 +409,7 @@ class MainActivity : AppCompatActivity() {
class MainActivityViewModel class MainActivityViewModel
@Inject @Inject
constructor( constructor(
@param:ApplicationContext private val context: Context,
private val preferences: DataStore<AppPreferences>, private val preferences: DataStore<AppPreferences>,
val serverRepository: ServerRepository, val serverRepository: ServerRepository,
private val navigationManager: SetupNavigationManager, private val navigationManager: SetupNavigationManager,
@ -379,9 +418,19 @@ class MainActivityViewModel
private val appUpgradeHandler: AppUpgradeHandler, private val appUpgradeHandler: AppUpgradeHandler,
) : ViewModel() { ) : ViewModel() {
fun appStart() { fun appStart() {
viewModelScope.launchIO { viewModelScope.launchDefault {
try { try {
appUpgradeHandler.run() val needUpgrade = appUpgradeHandler.needUpgrade()
if (needUpgrade) {
showToast(
context,
context.getString(
R.string.updated_toast,
appUpgradeHandler.currentVersion.toString(),
),
)
appUpgradeHandler.run()
}
appUpgradeHandler.copySubfont(false) appUpgradeHandler.copySubfont(false)
val prefs = val prefs =
preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
@ -424,7 +473,7 @@ class MainActivityViewModel
navigationManager.navigateTo(SetupDestination.ServerList) navigationManager.navigateTo(SetupDestination.ServerList)
} }
} }
viewModelScope.launchIO { viewModelScope.launchDefault {
// Create the mediaCodecCapabilitiesTest if needed // Create the mediaCodecCapabilitiesTest if needed
deviceProfileService.mediaCodecCapabilitiesTest.supportsAVC() deviceProfileService.mediaCodecCapabilitiesTest.supportsAVC()
} }

View file

@ -12,6 +12,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -33,10 +34,8 @@ import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.ui.components.AppScreensaver import com.github.damontecres.wholphin.ui.components.AppScreensaver
import com.github.damontecres.wholphin.ui.nav.ApplicationContent import com.github.damontecres.wholphin.ui.nav.ApplicationContent
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
@Composable @Composable
fun MainContent( fun MainContent(
@ -45,9 +44,9 @@ fun MainContent(
appPreferences: AppPreferences, appPreferences: AppPreferences,
backdropService: BackdropService, backdropService: BackdropService,
screensaverService: ScreensaverService, screensaverService: ScreensaverService,
requestedDestination: Destination,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val preferences by rememberUpdatedState(UserPreferences(appPreferences))
Surface( Surface(
modifier = modifier =
modifier modifier
@ -96,10 +95,6 @@ fun MainContent(
backdropService.clearBackdrop() backdropService.clearBackdrop()
} }
val current = key.current val current = key.current
val preferences =
remember(appPreferences) {
UserPreferences(appPreferences)
}
var showContent by remember { var showContent by remember {
mutableStateOf(true) mutableStateOf(true)
} }
@ -113,7 +108,6 @@ fun MainContent(
ApplicationContent( ApplicationContent(
user = current.user, user = current.user,
server = current.server, server = current.server,
startDestination = requestedDestination,
navigationManager = navigationManager, navigationManager = navigationManager,
preferences = preferences, preferences = preferences,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),

View file

@ -22,9 +22,7 @@ import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.components.AppScreensaverContent import com.github.damontecres.wholphin.ui.components.AppScreensaverContent
import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.theme.WholphinTheme
@ -33,6 +31,7 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@ -61,6 +60,7 @@ class WholphinDreamService :
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Timber.d("onCreate")
savedStateRegistryController.performRestore(null) savedStateRegistryController.performRestore(null)
lifecycleRegistry.currentState = Lifecycle.State.CREATED lifecycleRegistry.currentState = Lifecycle.State.CREATED
@ -74,6 +74,7 @@ class WholphinDreamService :
override fun onAttachedToWindow() { override fun onAttachedToWindow() {
super.onAttachedToWindow() super.onAttachedToWindow()
Timber.d("onAttachedToWindow")
val itemFlow = screensaverService.createItemFlow(lifecycleScope) val itemFlow = screensaverService.createItemFlow(lifecycleScope)
setContentView( setContentView(
ComposeView(this).apply { ComposeView(this).apply {
@ -109,11 +110,13 @@ class WholphinDreamService :
override fun onDreamingStarted() { override fun onDreamingStarted() {
super.onDreamingStarted() super.onDreamingStarted()
Timber.d("onDreamingStarted")
lifecycleRegistry.currentState = Lifecycle.State.STARTED lifecycleRegistry.currentState = Lifecycle.State.STARTED
} }
override fun onDreamingStopped() { override fun onDreamingStopped() {
super.onDreamingStopped() super.onDreamingStopped()
Timber.d("onDreamingStopped")
lifecycleRegistry.currentState = Lifecycle.State.DESTROYED lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
} }
} }

View file

@ -8,12 +8,18 @@ import com.github.damontecres.wholphin.ui.nav.Destination
import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.ExtraType import org.jellyfin.sdk.model.api.ExtraType
/**
* Represents "extras" for media such as behind-the-scenes or deleted scenes
*/
sealed interface ExtrasItem { sealed interface ExtrasItem {
val parentId: UUID val parentId: UUID
val type: ExtraType val type: ExtraType
val destination: Destination val destination: Destination
val title: String? val title: String?
/**
* Represents multiple extras of the same type
*/
data class Group( data class Group(
override val parentId: UUID, override val parentId: UUID,
override val type: ExtraType, override val type: ExtraType,
@ -25,6 +31,9 @@ sealed interface ExtrasItem {
override val title: String? = null override val title: String? = null
} }
/**
* Represents a single extra
*/
data class Single( data class Single(
override val parentId: UUID, override val parentId: UUID,
override val type: ExtraType, override val type: ExtraType,
@ -38,6 +47,9 @@ sealed interface ExtrasItem {
} }
} }
/**
* Converts [ExtraType] to the string resource ID
*/
@get:StringRes @get:StringRes
val ExtraType.stringRes: Int val ExtraType.stringRes: Int
get() = get() =
@ -56,6 +68,9 @@ val ExtraType.stringRes: Int
ExtraType.SHORT -> R.string.shorts ExtraType.SHORT -> R.string.shorts
} }
/**
* Converts [ExtraType] to the plural resource ID
*/
@get:PluralsRes @get:PluralsRes
val ExtraType.pluralRes: Int val ExtraType.pluralRes: Int
get() = get() =

View file

@ -7,6 +7,7 @@ import androidx.room.Query
import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.toServerString
import kotlinx.coroutines.flow.Flow
import java.util.UUID import java.util.UUID
@Dao @Dao
@ -27,6 +28,12 @@ interface LibraryDisplayInfoDao {
itemId: String, itemId: String,
): LibraryDisplayInfo? ): LibraryDisplayInfo?
@Query("SELECT * from LibraryDisplayInfo WHERE userId=:userId AND itemId=:itemId")
fun getItemAsFlow(
userId: Int,
itemId: String,
): Flow<LibraryDisplayInfo?>
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
fun saveItem(item: LibraryDisplayInfo): Long fun saveItem(item: LibraryDisplayInfo): Long

View file

@ -17,6 +17,19 @@ val DefaultFilterOptions =
DecadeFilter, DecadeFilter,
) )
val DefaultTvFilterOptions =
listOf(
PlayedFilter,
FavoriteFilter,
GenreFilter,
StudioFilter,
CommunityRatingFilter,
OfficialRatingFilter,
VideoTypeFilter,
YearFilter,
DecadeFilter,
)
val DefaultForFavoritesFilterOptions = val DefaultForFavoritesFilterOptions =
listOf( listOf(
PlayedFilter, PlayedFilter,
@ -32,6 +45,19 @@ val DefaultForGenresFilterOptions =
listOf( listOf(
PlayedFilter, PlayedFilter,
FavoriteFilter, FavoriteFilter,
StudioFilter,
CommunityRatingFilter,
OfficialRatingFilter,
VideoTypeFilter,
YearFilter,
DecadeFilter,
)
val DefaultForStudiosFilterOptions =
listOf(
PlayedFilter,
FavoriteFilter,
GenreFilter,
CommunityRatingFilter, CommunityRatingFilter,
OfficialRatingFilter, OfficialRatingFilter,
VideoTypeFilter, VideoTypeFilter,
@ -50,6 +76,11 @@ val DefaultPlaylistItemsOptions =
DecadeFilter, DecadeFilter,
) )
/**
* A way to filter libraries
*
* Gets and sets values within a [GetItemsFilter]
*/
sealed interface ItemFilterBy<T> { sealed interface ItemFilterBy<T> {
@get:StringRes @get:StringRes
val stringRes: Int val stringRes: Int
@ -178,3 +209,16 @@ data object CommunityRatingFilter : ItemFilterBy<Int> {
filter: GetItemsFilter, filter: GetItemsFilter,
): GetItemsFilter = filter.copy(minCommunityRating = value?.toDouble()) ): GetItemsFilter = filter.copy(minCommunityRating = value?.toDouble())
} }
data object StudioFilter : ItemFilterBy<List<UUID>> {
override val stringRes: Int = R.string.studios
override val supportMultiple: Boolean = true
override fun get(filter: GetItemsFilter): List<UUID>? = filter.studios
override fun set(
value: List<UUID>?,
filter: GetItemsFilter,
): GetItemsFilter = filter.copy(studios = value)
}

View file

@ -0,0 +1,51 @@
package com.github.damontecres.wholphin.data.model
import androidx.compose.runtime.Stable
import org.jellyfin.sdk.model.extensions.ticks
import java.util.UUID
import kotlin.time.Duration
/**
* Represents audio or a song as a stripped down [BaseItem] since there may be a lot of these created.
*
* Typically added to a MediaItem as the tag for reference later
*
* The "key" can be used by a Compose LazyList key function as it will uniquely identify this particular
* audio even if the same song is added to the queue multiple times
*/
@Stable
data class AudioItem(
val key: Long = keyTracker++,
val id: UUID,
val albumId: UUID?,
val artistId: UUID?,
val title: String?,
val albumTitle: String?,
val artistNames: String?,
val runtime: Duration?,
val imageUrl: String?,
val hasLyrics: Boolean,
) {
companion object {
private var keyTracker = 0L
fun from(
item: BaseItem,
imageUrl: String?,
): AudioItem =
AudioItem(
id = item.id,
albumId = item.data.albumId,
artistId =
item.data.artistItems
?.firstOrNull()
?.id,
title = item.title,
albumTitle = item.data.album,
artistNames = item.data.albumArtist,
runtime = item.data.runTimeTicks?.ticks,
imageUrl = imageUrl,
hasLyrics = item.data.hasLyrics == true,
)
}
}

View file

@ -5,12 +5,13 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import com.github.damontecres.wholphin.ui.DateFormatter
import com.github.damontecres.wholphin.ui.abbreviateNumber import com.github.damontecres.wholphin.ui.abbreviateNumber
import com.github.damontecres.wholphin.ui.detail.CardGridItem 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.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.dot import com.github.damontecres.wholphin.ui.dot
import com.github.damontecres.wholphin.ui.formatDateTime import com.github.damontecres.wholphin.ui.formatDateTime
import com.github.damontecres.wholphin.ui.getDateFormatter
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.playable import com.github.damontecres.wholphin.ui.playback.playable
import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.ui.roundMinutes
@ -28,11 +29,14 @@ import java.util.Locale
import java.util.UUID import java.util.UUID
import kotlin.time.Duration import kotlin.time.Duration
/**
* Wrapper for [BaseItemDto] with shortcuts for various UI elements
*/
@Serializable @Serializable
@Stable @Stable
data class BaseItem( data class BaseItem(
val data: BaseItemDto, val data: BaseItemDto,
val useSeriesForPrimary: Boolean, val useSeriesForPrimary: Boolean = false,
val imageUrlOverride: String? = null, val imageUrlOverride: String? = null,
val destinationOverride: Destination? = null, val destinationOverride: Destination? = null,
) : CardGridItem { ) : CardGridItem {
@ -57,6 +61,7 @@ data class BaseItem(
when (type) { when (type) {
BaseItemKind.EPISODE -> data.seasonEpisode + " - " + name BaseItemKind.EPISODE -> data.seasonEpisode + " - " + name
BaseItemKind.SERIES -> data.seriesProductionYears BaseItemKind.SERIES -> data.seriesProductionYears
BaseItemKind.AUDIO -> listOfNotNull(data.album, artistsString).joinToString(" - ")
else -> data.productionYear?.toString() else -> data.productionYear?.toString()
} }
@ -74,8 +79,7 @@ data class BaseItem(
val canDelete: Boolean get() = data.canDelete == true val canDelete: Boolean get() = data.canDelete == true
@Transient val aspectRatio: Float? get() = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 }
val aspectRatio: Float? = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 }
val indexNumber get() = data.indexNumber val indexNumber get() = data.indexNumber
@ -87,9 +91,11 @@ data class BaseItem(
val favorite get() = data.userData?.isFavorite ?: false val favorite get() = data.userData?.isFavorite ?: false
@Transient val timeRemainingOrRuntime: Duration? get() = data.timeRemaining ?: data.runTimeTicks?.ticks
val timeRemainingOrRuntime: Duration? = data.timeRemaining ?: data.runTimeTicks?.ticks
/**
* Contains pre computed UI elements that would be expensive to create on the main thread
*/
@Transient @Transient
val ui = val ui =
BaseItemUi( BaseItemUi(
@ -116,7 +122,7 @@ data class BaseItem(
buildList { buildList {
if (type == BaseItemKind.EPISODE) { if (type == BaseItemKind.EPISODE) {
data.seasonEpisode?.let(::add) data.seasonEpisode?.let(::add)
data.premiereDate?.let { add(DateFormatter.format(it)) } data.premiereDate?.let { add(getDateFormatter().format(it)) }
} else if (type == BaseItemKind.SERIES) { } else if (type == BaseItemKind.SERIES) {
data.seriesProductionYears?.let(::add) data.seriesProductionYears?.let(::add)
} else if (type == BaseItemKind.PHOTO) { } else if (type == BaseItemKind.PHOTO) {
@ -125,6 +131,9 @@ data class BaseItem(
} else if (data.premiereDate != null) { } else if (data.premiereDate != null) {
add(data.premiereDate!!.toLocalDate().toString()) add(data.premiereDate!!.toLocalDate().toString())
} }
} else if (type == BaseItemKind.BOX_SET) {
data.productionYear?.let { add(it.toString()) }
data.childCount?.let { add("$it items") }
} else { } else {
data.productionYear?.let { add(it.toString()) } data.productionYear?.let { add(it.toString()) }
} }
@ -173,6 +182,9 @@ data class BaseItem(
it.dayOfMonth.toString().padStart(2, '0') it.dayOfMonth.toString().padStart(2, '0')
}?.toIntOrNull() }?.toIntOrNull()
/**
* Convert this [BaseItem] into a [Destination] to navigate to its page in the app
*/
fun destination(index: Int? = null): Destination { fun destination(index: Int? = null): Destination {
if (destinationOverride != null) return destinationOverride if (destinationOverride != null) return destinationOverride
val result = val result =
@ -223,6 +235,7 @@ data class BaseItem(
} }
companion object { companion object {
@Deprecated("Use regular constructor instead")
fun from( fun from(
dto: BaseItemDto, dto: BaseItemDto,
api: ApiClient, api: ApiClient,
@ -244,6 +257,9 @@ data class BaseItemUi(
val quickDetails: AnnotatedString, val quickDetails: AnnotatedString,
) )
/**
* Create the special [Destination.FilteredCollection] for the given genre information
*/
fun createGenreDestination( fun createGenreDestination(
genreId: UUID, genreId: UUID,
genreName: String, genreName: String,
@ -252,6 +268,7 @@ fun createGenreDestination(
includeItemTypes: List<BaseItemKind>?, includeItemTypes: List<BaseItemKind>?,
) = Destination.FilteredCollection( ) = Destination.FilteredCollection(
itemId = parentId, itemId = parentId,
parentType = BaseItemKind.GENRE,
filter = filter =
CollectionFolderFilter( CollectionFolderFilter(
nameOverride = nameOverride =
@ -268,3 +285,31 @@ fun createGenreDestination(
), ),
recursive = true, recursive = true,
) )
fun createStudioDestination(
studioId: UUID,
name: String,
parentId: UUID,
parentName: String?,
includeItemTypes: List<BaseItemKind>?,
) = Destination.FilteredCollection(
itemId = parentId,
parentType = BaseItemKind.STUDIO,
filter =
CollectionFolderFilter(
nameOverride =
listOfNotNull(
name,
parentName,
).joinToString(" "),
filter =
GetItemsFilter(
studios = listOf(studioId),
includeItemTypes = includeItemTypes,
),
useSavedLibraryDisplayInfo = false,
),
recursive = true,
)
val BaseItem.studioNames get() = data.studios?.mapNotNull { it.name }.orEmpty()

View file

@ -7,6 +7,9 @@ import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import kotlin.time.Duration import kotlin.time.Duration
/**
* Represents a chapter within a video
*/
data class Chapter( data class Chapter(
val name: String?, val name: String?,
val position: Duration, val position: Duration,

View file

@ -16,6 +16,9 @@ import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.time.LocalDate import java.time.LocalDate
import java.util.UUID import java.util.UUID
/**
* The type of a Seerr/Discover object with mapping to the Jellyfin [BaseItemKind]
*/
@Serializable @Serializable
enum class SeerrItemType( enum class SeerrItemType(
val baseItemKind: BaseItemKind?, val baseItemKind: BaseItemKind?,
@ -46,6 +49,9 @@ enum class SeerrItemType(
} }
} }
/**
* How available is a particular discovered item within the Jellyfin server
*/
@Serializable @Serializable
enum class SeerrAvailability( enum class SeerrAvailability(
val status: Int, val status: Int,
@ -64,7 +70,7 @@ enum class SeerrAvailability(
} }
/** /**
* An item provided by a discovery service (ie Seerr). It may exist on the JF server as well. * An item provided by a discovery service (ie Seerr). It may exist on the JF server as well, see [availability].
*/ */
@Stable @Stable
@Serializable @Serializable
@ -104,6 +110,9 @@ data class DiscoverItem(
} }
} }
/**
* A rating for a discovered item which is usually fetched separately from the item
*/
data class DiscoverRating( data class DiscoverRating(
val criticRating: Int?, val criticRating: Int?,
val audienceRating: Float?, val audienceRating: Float?,

View file

@ -14,6 +14,9 @@ import org.jellyfin.sdk.model.api.request.GetPersonsRequest
import org.jellyfin.sdk.model.serializer.UUIDSerializer import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID import java.util.UUID
/**
* Filter for a collection folder
*/
@Serializable @Serializable
data class CollectionFolderFilter( data class CollectionFolderFilter(
val nameOverride: String? = null, val nameOverride: String? = null,
@ -24,6 +27,9 @@ data class CollectionFolderFilter(
val useSavedLibraryDisplayInfo: Boolean = true, val useSavedLibraryDisplayInfo: Boolean = true,
) )
/**
* A sort of simplified filter which can be [applyTo] a [GetItemsRequest] or [GetPersonsRequest] to add or remove filters
*/
@Serializable @Serializable
data class GetItemsFilter( data class GetItemsFilter(
val favorite: Boolean? = null, val favorite: Boolean? = null,
@ -52,7 +58,7 @@ data class GetItemsFilter(
} }
/** /**
* Clear all of the values for the given filters * Clear all the values for the given filters
*/ */
fun delete(filterOptions: List<ItemFilterBy<*>>): GetItemsFilter { fun delete(filterOptions: List<ItemFilterBy<*>>): GetItemsFilter {
var newFilter = this var newFilter = this
@ -128,6 +134,9 @@ data class GetItemsFilter(
isFavorite = favorite, isFavorite = favorite,
) )
/**
* Merge another [GetItemsFilter] onto this one, replacing only unset values
*/
fun merge(filter: GetItemsFilter): GetItemsFilter = fun merge(filter: GetItemsFilter): GetItemsFilter =
this.copy( this.copy(
favorite = favorite ?: filter.favorite, favorite = favorite ?: filter.favorite,

View file

@ -90,6 +90,18 @@ sealed interface HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): Genres = this.copy(viewOptions = viewOptions) override fun updateViewOptions(viewOptions: HomeRowViewOptions): Genres = this.copy(viewOptions = viewOptions)
} }
/**
* Row of a studios in a library
*/
@Serializable
@SerialName("Studios")
data class Studios(
val parentId: UUID,
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions.genreDefault,
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): Studios = this.copy(viewOptions = viewOptions)
}
/** /**
* Favorites for a specific type * Favorites for a specific type
*/ */
@ -162,7 +174,7 @@ sealed interface HomeRowConfig {
@SerialName("ByParent") @SerialName("ByParent")
data class ByParent( data class ByParent(
val parentId: UUID, val parentId: UUID,
val recursive: Boolean, val recursive: Boolean = false,
val sort: SortAndDirection? = null, val sort: SortAndDirection? = null,
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowConfig { ) : HomeRowConfig {

View file

@ -13,6 +13,10 @@ import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.serializer.UUIDSerializer import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID import java.util.UUID
/**
* Store the media source and audio/subtitle tracks chosen for a specific media item
*
*/
@Entity( @Entity(
foreignKeys = [ foreignKeys = [
ForeignKey( ForeignKey(

View file

@ -9,6 +9,11 @@ import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.serializer.UUIDSerializer import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID import java.util.UUID
/**
* Store modifications to an audio/subtitle track in a media item
*
* For example, the subtitle delay
*/
@Entity( @Entity(
foreignKeys = [ foreignKeys = [
ForeignKey( ForeignKey(

View file

@ -17,6 +17,9 @@ import org.jellyfin.sdk.model.ServerVersion
import org.jellyfin.sdk.model.serializer.UUIDSerializer import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID import java.util.UUID
/**
* Represents a Jellyfin server
*/
@Entity(tableName = "servers") @Entity(tableName = "servers")
@Serializable @Serializable
data class JellyfinServer( data class JellyfinServer(
@ -29,6 +32,9 @@ data class JellyfinServer(
val serverVersion: ServerVersion? by lazy { version?.let(ServerVersion::fromString) } val serverVersion: ServerVersion? by lazy { version?.let(ServerVersion::fromString) }
} }
/**
* Represents a Jellyfin user for a particular server
*/
@Entity( @Entity(
tableName = "users", tableName = "users",
foreignKeys = [ foreignKeys = [
@ -59,6 +65,9 @@ data class JellyfinUser(
"JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()})" "JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()})"
} }
/**
* Represents the relationship between [JellyfinServer] and its [JellyfinUser]
*/
data class JellyfinServerUsers( data class JellyfinServerUsers(
@Embedded val server: JellyfinServer, @Embedded val server: JellyfinServer,
@Relation( @Relation(

View file

@ -9,13 +9,20 @@ import androidx.room.Ignore
import androidx.room.Index import androidx.room.Index
import com.github.damontecres.wholphin.ui.components.ViewOptions import com.github.damontecres.wholphin.ui.components.ViewOptions
import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.toServerString
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient import kotlinx.serialization.Transient
import kotlinx.serialization.UseSerializers import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.serializer.UUIDSerializer import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID
/**
* Stores the filter, sort, and view options a user changes for a library
*
* This allows for restoring these settings whenever the user navigates to the library
*/
@Entity( @Entity(
foreignKeys = [ foreignKeys = [
ForeignKey( ForeignKey(
@ -41,4 +48,13 @@ data class LibraryDisplayInfo(
) { ) {
@Ignore @Transient @Ignore @Transient
val sortAndDirection = SortAndDirection(sort, direction) val sortAndDirection = SortAndDirection(sort, direction)
constructor(
user: JellyfinUser,
itemId: UUID,
sort: ItemSortBy,
direction: SortOrder,
filter: GetItemsFilter,
viewOptions: ViewOptions?,
) : this(user.rowId, itemId.toServerString(), sort, direction, filter, viewOptions)
} }

View file

@ -9,6 +9,9 @@ enum class NavPinType {
UNPINNED, UNPINNED,
} }
/**
* Stores preference information about nav drawer items such as its order and whether to show or put in More
*/
@Entity( @Entity(
foreignKeys = [ foreignKeys = [
ForeignKey( ForeignKey(

View file

@ -12,6 +12,9 @@ import org.jellyfin.sdk.model.api.BaseItemPerson
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.PersonKind import org.jellyfin.sdk.model.api.PersonKind
/**
* Represents a person in some media such as an actor or director
*/
@Stable @Stable
data class Person( data class Person(
val id: UUID, val id: UUID,

View file

@ -5,6 +5,9 @@ import androidx.room.Entity
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import java.util.UUID import java.util.UUID
/**
* Store effects applied to some media such as color or hue adjustments
*/
@Entity(tableName = "playback_effects", primaryKeys = ["jellyfinUserRowId", "itemId", "type"]) @Entity(tableName = "playback_effects", primaryKeys = ["jellyfinUserRowId", "itemId", "type"])
data class PlaybackEffect( data class PlaybackEffect(
val jellyfinUserRowId: Int, val jellyfinUserRowId: Int,

View file

@ -9,6 +9,10 @@ import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.serializer.UUIDSerializer import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID import java.util.UUID
/**
* Stores the language choices for a series so they can be applied automatically to other episodes
* without the user needing to explicitly choose the tracks
*/
@Entity( @Entity(
foreignKeys = [ foreignKeys = [
ForeignKey( ForeignKey(

View file

@ -6,6 +6,11 @@ import androidx.compose.runtime.setValue
import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.MediaType
import java.util.UUID import java.util.UUID
/**
* Tracks playback of multiple items. Points to the current media with function to advance or go to previous ones.
*
* This is not the same thing as a Jellyfin server playlist
*/
class Playlist( class Playlist(
items: List<BaseItem>, items: List<BaseItem>,
startIndex: Int = 0, startIndex: Int = 0,

View file

@ -2,6 +2,9 @@ package com.github.damontecres.wholphin.data.model
import com.github.damontecres.wholphin.services.SeerrUserConfig import com.github.damontecres.wholphin.services.SeerrUserConfig
/**
* Permission levels for a user on a Seerr server
*/
enum class SeerrPermission( enum class SeerrPermission(
private val flag: Int, private val flag: Int,
) { ) {

View file

@ -13,6 +13,9 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.serializer.UUIDSerializer import org.jellyfin.sdk.model.serializer.UUIDSerializer
/**
* Represents a Seerr server instance
*/
@Entity( @Entity(
tableName = "seerr_servers", tableName = "seerr_servers",
indices = [Index("url", unique = true)], indices = [Index("url", unique = true)],
@ -26,6 +29,9 @@ data class SeerrServer(
val version: String? = null, val version: String? = null,
) )
/**
* Represents a user on a [SeerrServer]
*/
@Entity( @Entity(
tableName = "seerr_users", tableName = "seerr_users",
foreignKeys = [ foreignKeys = [
@ -56,12 +62,18 @@ data class SeerrUser(
"SeerrUser(jellyfinUserRowId=$jellyfinUserRowId, serverId=$serverId, authMethod=$authMethod, username=$username, password?=${password.isNotNullOrBlank()}, credential?=${credential.isNotNullOrBlank()})" "SeerrUser(jellyfinUserRowId=$jellyfinUserRowId, serverId=$serverId, authMethod=$authMethod, username=$username, password?=${password.isNotNullOrBlank()}, credential?=${credential.isNotNullOrBlank()})"
} }
/**
* The method used to authenticate a user to the server
*/
enum class SeerrAuthMethod { enum class SeerrAuthMethod {
LOCAL, LOCAL,
JELLYFIN, JELLYFIN,
API_KEY, API_KEY,
} }
/**
* Represents the relationship between a [SeerrServer] and its [SeerrUser]s
*/
data class SeerrServerUsers( data class SeerrServerUsers(
@Embedded val server: SeerrServer, @Embedded val server: SeerrServer,
@Relation( @Relation(

View file

@ -1,9 +1,15 @@
package com.github.damontecres.wholphin.data.model package com.github.damontecres.wholphin.data.model
/**
* Represents a trailer for media
*/
sealed interface Trailer { sealed interface Trailer {
val name: String val name: String
} }
/**
* A [Trailer] stored on the Jellyfin server
*/
data class LocalTrailer( data class LocalTrailer(
val baseItem: BaseItem, val baseItem: BaseItem,
) : Trailer { ) : Trailer {
@ -11,6 +17,9 @@ data class LocalTrailer(
get() = baseItem.name ?: "" get() = baseItem.name ?: ""
} }
/**
* A [Trailer] available via a remote URL, such as YouTube
*/
data class RemoteTrailer( data class RemoteTrailer(
override val name: String, override val name: String,
val url: String, val url: String,

View file

@ -14,7 +14,7 @@ import androidx.media3.effect.ScaleAndRotateTransformation
import androidx.room.Ignore import androidx.room.Ignore
/** /**
* Modifications to a video playback * Modifications to an image or video playback
*/ */
data class VideoFilter( data class VideoFilter(
val rotation: Int = 0, val rotation: Int = 0,

View file

@ -5,6 +5,7 @@ import androidx.annotation.ArrayRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.content.edit import androidx.core.content.edit
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.services.UpdateChecker import com.github.damontecres.wholphin.services.UpdateChecker
@ -62,7 +63,7 @@ sealed interface AppPreference<Pref, T> {
AppSliderPreference<AppPreferences>( AppSliderPreference<AppPreferences>(
title = R.string.skip_forward_preference, title = R.string.skip_forward_preference,
defaultValue = 30, defaultValue = 30,
min = 10, min = 5,
max = 5.minutes.inWholeSeconds, max = 5.minutes.inWholeSeconds,
interval = 5, interval = 5,
getter = { getter = {
@ -464,16 +465,18 @@ sealed interface AppPreference<Pref, T> {
summaryOn = R.string.enabled, summaryOn = R.string.enabled,
summaryOff = R.string.disabled, summaryOff = R.string.disabled,
) )
val DirectPlayAss = val AssSubtitleMode =
AppSwitchPreference<AppPreferences>( AppChoicePreference<AppPreferences, AssPlaybackMode>(
title = R.string.direct_play_ass, title = R.string.ass_subtitle_playback,
defaultValue = true, defaultValue = AssPlaybackMode.ASS_LIBASS,
getter = { it.playbackPreferences.overrides.directPlayAss }, getter = { it.playbackPreferences.overrides.assPlaybackMode },
setter = { prefs, value -> setter = { prefs, value ->
prefs.updatePlaybackOverrides { directPlayAss = value } prefs.updatePlaybackOverrides { assPlaybackMode = value }
}, },
summaryOn = R.string.enabled, displayValues = R.array.ass_subtitle_modes,
summaryOff = R.string.disabled, subtitles = R.array.ass_subtitle_modes_summary,
indexToValue = { AssPlaybackMode.forNumber(it) },
valueToIndex = { if (it != AssPlaybackMode.UNRECOGNIZED) it.number else AssPlaybackMode.ASS_LIBASS.number },
) )
val DirectPlayPgs = val DirectPlayPgs =
AppSwitchPreference<AppPreferences>( AppSwitchPreference<AppPreferences>(
@ -535,6 +538,18 @@ sealed interface AppPreference<Pref, T> {
valueToIndex = { if (it != AppThemeColors.UNRECOGNIZED) it.number else 0 }, valueToIndex = { if (it != AppThemeColors.UNRECOGNIZED) it.number else 0 },
) )
val ShowLogos =
AppSwitchPreference<AppPreferences>(
title = R.string.prefer_logos,
defaultValue = true,
getter = { it.interfacePreferences.showLogos },
setter = { prefs, value ->
prefs.updateInterfacePreferences { showLogos = value }
},
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
val InstalledVersion = val InstalledVersion =
AppClickablePreference<AppPreferences>( AppClickablePreference<AppPreferences>(
title = R.string.installed_version, title = R.string.installed_version,
@ -845,8 +860,19 @@ sealed interface AppPreference<Pref, T> {
}, },
displayValues = R.array.player_backend_options, displayValues = R.array.player_backend_options,
subtitles = R.array.player_backend_options_subtitles, subtitles = R.array.player_backend_options_subtitles,
indexToValue = { PlayerBackend.forNumber(it) }, indexToValue = { PlayerBackend.forNumber(it) ?: PlayerBackend.EXO_PLAYER },
valueToIndex = { it.number }, valueToIndex = { if (it != PlayerBackend.UNRECOGNIZED) it.number else PlayerBackend.EXO_PLAYER.number },
)
val ExternalPlayerApp =
AppStringPreference<AppPreferences>(
title = R.string.external_player,
defaultValue = "",
getter = { it.playbackPreferences.externalPlayer },
setter = { prefs, value ->
prefs.updatePlaybackPreferences { externalPlayer = value }
},
summary = null,
) )
val ExoPlayerSettings = val ExoPlayerSettings =
@ -1107,10 +1133,12 @@ val basicPreferences =
PreferenceGroup( PreferenceGroup(
title = R.string.more, title = R.string.more,
preferences = preferences =
listOf( buildList {
AppPreference.SeerrIntegration, if (BuildConfig.DISCOVER_ENABLED) {
AppPreference.AdvancedSettings, add(AppPreference.SeerrIntegration)
), }
add(AppPreference.AdvancedSettings)
},
), ),
) )
@ -1120,7 +1148,7 @@ private val ExoPlayerSettings =
AppPreference.DownMixStereo, AppPreference.DownMixStereo,
AppPreference.Ac3Supported, AppPreference.Ac3Supported,
AppPreference.PreferAc3ForSurround, AppPreference.PreferAc3ForSurround,
AppPreference.DirectPlayAss, AppPreference.AssSubtitleMode,
AppPreference.DirectPlayPgs, AppPreference.DirectPlayPgs,
AppPreference.DirectPlayDoviProfile7, AppPreference.DirectPlayDoviProfile7,
AppPreference.DecodeAv1, AppPreference.DecodeAv1,
@ -1157,6 +1185,7 @@ val advancedPreferences =
preferences = preferences =
listOf( listOf(
AppPreference.ShowClock, AppPreference.ShowClock,
AppPreference.ShowLogos,
AppPreference.ManageMedia, AppPreference.ManageMedia,
AppPreference.CombineContinueNext, AppPreference.CombineContinueNext,
// Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418 // Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418
@ -1216,6 +1245,10 @@ val advancedPreferences =
AppPreference.MpvSettings, AppPreference.MpvSettings,
), ),
), ),
ConditionalPreferences(
{ it.playbackPreferences.playerBackend == PlayerBackend.EXTERNAL_PLAYER },
listOf(AppPreference.ExternalPlayerApp),
),
), ),
), ),
) )

View file

@ -61,10 +61,11 @@ class AppPreferencesSerializer
.apply { .apply {
ac3Supported = AppPreference.Ac3Supported.defaultValue ac3Supported = AppPreference.Ac3Supported.defaultValue
downmixStereo = AppPreference.DownMixStereo.defaultValue downmixStereo = AppPreference.DownMixStereo.defaultValue
directPlayAss = AppPreference.DirectPlayAss.defaultValue // directPlayAss = AppPreference.DirectPlayAss.defaultValue
directPlayPgs = AppPreference.DirectPlayPgs.defaultValue directPlayPgs = AppPreference.DirectPlayPgs.defaultValue
mediaExtensionsEnabled = mediaExtensionsEnabled =
AppPreference.FfmpegPreference.defaultValue AppPreference.FfmpegPreference.defaultValue
assPlaybackMode = AppPreference.AssSubtitleMode.defaultValue
}.build() }.build()
mpvOptions = mpvOptions =
@ -95,6 +96,7 @@ class AppPreferencesSerializer
AppPreference.NavDrawerSwitchOnFocus.defaultValue AppPreference.NavDrawerSwitchOnFocus.defaultValue
showClock = AppPreference.ShowClock.defaultValue showClock = AppPreference.ShowClock.defaultValue
backdropStyle = AppPreference.BackdropStylePref.defaultValue backdropStyle = AppPreference.BackdropStylePref.defaultValue
showLogos = AppPreference.ShowLogos.defaultValue
subtitlesPreferences = subtitlesPreferences =
SubtitlePreferences SubtitlePreferences
@ -152,6 +154,15 @@ class AppPreferencesSerializer
slideshowDuration = AppPreference.SlideshowDuration.defaultValue slideshowDuration = AppPreference.SlideshowDuration.defaultValue
slideshowPlayVideos = AppPreference.SlideshowPlayVideos.defaultValue slideshowPlayVideos = AppPreference.SlideshowPlayVideos.defaultValue
}.build() }.build()
musicPreferences =
MusicPreferences
.newBuilder()
.apply {
showBackdrop = true
showLyrics = true
showAlbumArt = true
}.build()
}.build() }.build()
override suspend fun readFrom(input: InputStream): AppPreferences { override suspend fun readFrom(input: InputStream): AppPreferences {
@ -220,6 +231,11 @@ inline fun AppPreferences.updateScreensaverPreferences(block: ScreensaverPrefere
screensaverPreference = screensaverPreference.toBuilder().apply(block).build() screensaverPreference = screensaverPreference.toBuilder().apply(block).build()
} }
inline fun AppPreferences.updateMusicPreferences(block: MusicPreferences.Builder.() -> Unit): AppPreferences =
update {
musicPreferences = musicPreferences.toBuilder().apply(block).build()
}
fun SubtitlePreferences.Builder.resetSubtitles() { fun SubtitlePreferences.Builder.resetSubtitles() {
fontSize = SubtitleSettings.FontSize.defaultValue.toInt() fontSize = SubtitleSettings.FontSize.defaultValue.toInt()
fontColor = SubtitleSettings.FontColor.defaultValue.toArgb() fontColor = SubtitleSettings.FontColor.defaultValue.toArgb()

View file

@ -1,11 +1,11 @@
package com.github.damontecres.wholphin.services package com.github.damontecres.wholphin.services
import android.content.Context import android.content.Context
import android.content.pm.PackageInfo
import android.os.Build import android.os.Build
import androidx.core.content.edit import androidx.core.content.edit
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.data.SeerrServerDao import com.github.damontecres.wholphin.data.SeerrServerDao
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.preferences.updateHomePagePreferences
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences
import com.github.damontecres.wholphin.preferences.updateMpvOptions import com.github.damontecres.wholphin.preferences.updateMpvOptions
import com.github.damontecres.wholphin.preferences.updateMusicPreferences
import com.github.damontecres.wholphin.preferences.updatePhotoPreferences import com.github.damontecres.wholphin.preferences.updatePhotoPreferences
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
import com.github.damontecres.wholphin.preferences.updateScreensaverPreferences import com.github.damontecres.wholphin.preferences.updateScreensaverPreferences
@ -31,6 +32,9 @@ import java.io.File
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/**
* Handles any changes needed when the app in upgraded on the device such as setting new preferences
*/
@Singleton @Singleton
class AppUpgradeHandler class AppUpgradeHandler
@Inject @Inject
@ -39,12 +43,10 @@ class AppUpgradeHandler
private val appPreferences: DataStore<AppPreferences>, private val appPreferences: DataStore<AppPreferences>,
private val seerrServerDao: SeerrServerDao, private val seerrServerDao: SeerrServerDao,
) { ) {
suspend fun run() { val pkgInfo: PackageInfo get() = context.packageManager.getPackageInfo(context.packageName, 0)
val pkgInfo = val currentVersion: Version get() = Version.fromString(pkgInfo.versionName!!)
WholphinApplication.instance.packageManager.getPackageInfo(
WholphinApplication.instance.packageName, fun needUpgrade(): Boolean {
0,
)
val prefs = PreferenceManager.getDefaultSharedPreferences(context) val prefs = PreferenceManager.getDefaultSharedPreferences(context)
val previousVersion = prefs.getString(VERSION_NAME_CURRENT_KEY, null) val previousVersion = prefs.getString(VERSION_NAME_CURRENT_KEY, null)
val previousVersionCode = prefs.getLong(VERSION_CODE_CURRENT_KEY, -1) val previousVersionCode = prefs.getLong(VERSION_CODE_CURRENT_KEY, -1)
@ -56,32 +58,48 @@ class AppUpgradeHandler
} else { } else {
pkgInfo.versionCode.toLong() pkgInfo.versionCode.toLong()
} }
if (newVersion != previousVersion || newVersionCode != previousVersionCode) { Timber.i(
Timber.i( "App versions: $previousVersion=>$newVersion, $previousVersionCode=>$newVersionCode",
"App updated: $previousVersion=>$newVersion, $previousVersionCode=>$newVersionCode", )
) return newVersion != previousVersion || newVersionCode != previousVersionCode
prefs.edit(true) {
putString(VERSION_NAME_PREVIOUS_KEY, previousVersion)
putLong(VERSION_CODE_PREVIOUS_KEY, previousVersionCode)
putString(VERSION_NAME_CURRENT_KEY, newVersion)
putLong(VERSION_CODE_CURRENT_KEY, newVersionCode)
}
try {
copySubfont(true)
upgradeApp(
Version.fromString(previousVersion ?: "0.0.0"),
Version.fromString(newVersion),
appPreferences,
)
} catch (ex: Exception) {
Timber.e(ex, "Exception during app upgrade")
}
Timber.i("App upgrade complete")
} else {
Timber.d("No app update needed")
}
} }
suspend fun run() {
Timber.i("App upgrade started")
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
val previousVersion = prefs.getString(VERSION_NAME_CURRENT_KEY, null)
val previousVersionCode = prefs.getLong(VERSION_CODE_CURRENT_KEY, -1)
val newVersion = pkgInfo.versionName!!
val newVersionCode =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
pkgInfo.longVersionCode
} else {
pkgInfo.versionCode.toLong()
}
// Store the previous and new version info
prefs.edit(true) {
putString(VERSION_NAME_PREVIOUS_KEY, previousVersion)
putLong(VERSION_CODE_PREVIOUS_KEY, previousVersionCode)
putString(VERSION_NAME_CURRENT_KEY, newVersion)
putLong(VERSION_CODE_CURRENT_KEY, newVersionCode)
}
try {
copySubfont(true)
upgradeApp(
Version.fromString(previousVersion ?: "0.0.0"),
Version.fromString(newVersion),
appPreferences,
)
} catch (ex: Exception) {
Timber.e(ex, "Exception during app upgrade")
}
Timber.i("App upgrade complete")
}
/**
* Copies the font file used by MPV subtitles to the app's files directory
*/
fun copySubfont(overwrite: Boolean) { fun copySubfont(overwrite: Boolean) {
try { try {
val fontFileName = "subfont.ttf" val fontFileName = "subfont.ttf"
@ -110,6 +128,9 @@ class AppUpgradeHandler
const val VERSION_CODE_CURRENT_KEY = "version.current.code" const val VERSION_CODE_CURRENT_KEY = "version.current.code"
} }
/**
* Perform any needed upgrades
*/
suspend fun upgradeApp( suspend fun upgradeApp(
previous: Version, previous: Version,
current: Version, current: Version,
@ -120,7 +141,7 @@ class AppUpgradeHandler
it.updatePlaybackOverrides { it.updatePlaybackOverrides {
ac3Supported = AppPreference.Ac3Supported.defaultValue ac3Supported = AppPreference.Ac3Supported.defaultValue
downmixStereo = AppPreference.DownMixStereo.defaultValue downmixStereo = AppPreference.DownMixStereo.defaultValue
directPlayAss = AppPreference.DirectPlayAss.defaultValue // directPlayAss = AppPreference.DirectPlayAss.defaultValue
directPlayPgs = AppPreference.DirectPlayPgs.defaultValue directPlayPgs = AppPreference.DirectPlayPgs.defaultValue
} }
} }
@ -280,5 +301,31 @@ class AppUpgradeHandler
) )
} }
} }
if (previous.isEqualOrBefore(Version.fromString("0.5.4-6-g0"))) {
appPreferences.updateData {
it.updateMusicPreferences {
showBackdrop = true
showLyrics = true
showAlbumArt = true
}
}
}
if (previous.isEqualOrBefore(Version.fromString("0.5.4-15-g0"))) {
appPreferences.updateData {
it.updateInterfacePreferences {
showLogos = AppPreference.ShowLogos.defaultValue
}
}
}
if (previous.isEqualOrBefore(Version.fromString("0.6.2-1-g0"))) {
appPreferences.updateData {
it.updatePlaybackOverrides {
assPlaybackMode = AppPreference.AssSubtitleMode.defaultValue
}
}
}
} }
} }

View file

@ -32,6 +32,11 @@ import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/**
* Stores state for the backdrop of the app shown on non-full screen pages
*
* This is usually the backdrop of the currently focused media
*/
@Singleton @Singleton
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
class BackdropService class BackdropService
@ -46,6 +51,9 @@ class BackdropService
private val _backdropFlow = MutableStateFlow<BackdropResult>(BackdropResult.NONE) private val _backdropFlow = MutableStateFlow<BackdropResult>(BackdropResult.NONE)
val backdropFlow = _backdropFlow val backdropFlow = _backdropFlow
/**
* Update the backdrop to use the specified item
*/
suspend fun submit(item: BaseItem) = suspend fun submit(item: BaseItem) =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val imageUrl = val imageUrl =
@ -57,8 +65,14 @@ class BackdropService
submit(item.id.toString(), imageUrl) submit(item.id.toString(), imageUrl)
} }
/**
* Update the backdrop to use the specified discovered item
*/
suspend fun submit(item: DiscoverItem) = submit("discover_${item.id}", item.backDropUrl) suspend fun submit(item: DiscoverItem) = submit("discover_${item.id}", item.backDropUrl)
/**
* Update the backdrop to use the specified URL
*/
suspend fun submit( suspend fun submit(
itemId: String, itemId: String,
imageUrl: String?, imageUrl: String?,
@ -74,6 +88,9 @@ class BackdropService
} }
} }
/**
* Remove the backdrop, such as when switching pages
*/
suspend fun clearBackdrop() { suspend fun clearBackdrop() {
_backdropFlow.update { _backdropFlow.update {
BackdropResult.NONE BackdropResult.NONE
@ -114,7 +131,7 @@ class BackdropService
} }
} }
private suspend fun extractColorsFromBackdrop(imageUrl: String?): ExtractedColors = suspend fun extractColorsFromBackdrop(imageUrl: String?): ExtractedColors =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
if (imageUrl.isNullOrBlank()) { if (imageUrl.isNullOrBlank()) {
return@withContext ExtractedColors.DEFAULT return@withContext ExtractedColors.DEFAULT
@ -224,6 +241,9 @@ class BackdropService
} }
} }
/**
* The result from determining the backdrop URL and extracted colors for the dynamic backdrop
*/
data class BackdropResult( data class BackdropResult(
val itemId: String?, val itemId: String?,
val imageUrl: String?, val imageUrl: String?,
@ -245,6 +265,9 @@ data class BackdropResult(
} }
} }
/**
* The colors extracted from an image
*/
data class ExtractedColors( data class ExtractedColors(
val primary: Color, val primary: Color,
val secondary: Color, val secondary: Color,

View file

@ -27,6 +27,9 @@ import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/**
* Caches when media was lasted played. This is mostly used by the combined Continue Watching & Next Up home row.
*/
@Singleton @Singleton
class DatePlayedService class DatePlayedService
@Inject @Inject
@ -80,6 +83,12 @@ class DatePlayedService
} }
} }
/**
* Get the logical timestamp when the item was last played.
*
* This is calculated using lastest of the actual last played timestamp of the item,
* the previous episode's last played timestamp, and the episode's premiere date
*/
suspend fun getLastPlayed(item: BaseItem): LocalDateTime? = suspend fun getLastPlayed(item: BaseItem): LocalDateTime? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val seriesId = item.data.seriesId val seriesId = item.data.seriesId
@ -93,6 +102,11 @@ class DatePlayedService
} }
} }
/**
* Remove the cached last date played for the given item's series
*
* Used for when a user plays this item
*/
fun invalidate(item: BaseItem) { fun invalidate(item: BaseItem) {
item.data.seriesId?.let { seriesId -> item.data.seriesId?.let { seriesId ->
Timber.d("Invalidating %s", seriesId) Timber.d("Invalidating %s", seriesId)
@ -100,6 +114,9 @@ class DatePlayedService
} }
} }
/**
* Remove the cached last data played for the given item or its series
*/
suspend fun invalidate(itemId: UUID) { suspend fun invalidate(itemId: UUID) {
val seriesId = val seriesId =
api.userLibraryApi.getItem(itemId = itemId).content.let { api.userLibraryApi.getItem(itemId = itemId).content.let {
@ -121,6 +138,9 @@ class DatePlayedService
} }
} }
/**
* Invalidates the date played cached when the current user changes
*/
@ActivityScoped @ActivityScoped
class DatePlayedInvalidationService class DatePlayedInvalidationService
@Inject @Inject

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.services package com.github.damontecres.wholphin.services
import android.content.Context import android.content.Context
import com.github.damontecres.wholphin.preferences.AssPlaybackMode
import com.github.damontecres.wholphin.preferences.PlaybackPreferences import com.github.damontecres.wholphin.preferences.PlaybackPreferences
import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest
import com.github.damontecres.wholphin.util.profile.createDeviceProfile import com.github.damontecres.wholphin.util.profile.createDeviceProfile
@ -14,6 +15,9 @@ import org.jellyfin.sdk.model.api.DeviceProfile
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/**
* Creates and caches the device direct play/transcoding profile sent to the server for ExoPlayer
*/
@Singleton @Singleton
class DeviceProfileService class DeviceProfileService
@Inject @Inject
@ -21,7 +25,7 @@ class DeviceProfileService
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
) { ) {
val mediaCodecCapabilitiesTest by lazy { val mediaCodecCapabilitiesTest by lazy {
// Created lazily below on the IO thread since it cn take time // Created lazily below on another thread since it cn take time
MediaCodecCapabilitiesTest(context) MediaCodecCapabilitiesTest(context)
} }
private val mutex = Mutex() private val mutex = Mutex()
@ -33,14 +37,14 @@ class DeviceProfileService
prefs: PlaybackPreferences, prefs: PlaybackPreferences,
serverVersion: ServerVersion?, serverVersion: ServerVersion?,
): DeviceProfile = ): DeviceProfile =
withContext(Dispatchers.IO) { withContext(Dispatchers.Default) {
mutex.withLock { mutex.withLock {
val newConfig = val newConfig =
DeviceProfileConfiguration( DeviceProfileConfiguration(
maxBitrate = prefs.maxBitrate.toInt(), maxBitrate = prefs.maxBitrate.toInt(),
isAC3Enabled = prefs.overrides.ac3Supported, isAC3Enabled = prefs.overrides.ac3Supported,
downMixAudio = prefs.overrides.downmixStereo, downMixAudio = prefs.overrides.downmixStereo,
assDirectPlay = prefs.overrides.directPlayAss, assPlaybackMode = prefs.overrides.assPlaybackMode,
pgsDirectPlay = prefs.overrides.directPlayPgs, pgsDirectPlay = prefs.overrides.directPlayPgs,
dolbyVisionELDirectPlay = prefs.overrides.directPlayDolbyVisionEL, dolbyVisionELDirectPlay = prefs.overrides.directPlayDolbyVisionEL,
decodeAv1 = prefs.overrides.decodeAv1, decodeAv1 = prefs.overrides.decodeAv1,
@ -56,7 +60,7 @@ class DeviceProfileService
maxBitrate = newConfig.maxBitrate, maxBitrate = newConfig.maxBitrate,
isAC3Enabled = newConfig.isAC3Enabled, isAC3Enabled = newConfig.isAC3Enabled,
downMixAudio = newConfig.downMixAudio, downMixAudio = newConfig.downMixAudio,
assDirectPlay = newConfig.assDirectPlay, assDirectPlay = newConfig.assPlaybackMode != AssPlaybackMode.ASS_TRANSCODE,
pgsDirectPlay = newConfig.pgsDirectPlay, pgsDirectPlay = newConfig.pgsDirectPlay,
dolbyVisionELDirectPlay = newConfig.dolbyVisionELDirectPlay, dolbyVisionELDirectPlay = newConfig.dolbyVisionELDirectPlay,
decodeAv1 = prefs.overrides.decodeAv1, decodeAv1 = prefs.overrides.decodeAv1,
@ -76,7 +80,7 @@ data class DeviceProfileConfiguration(
val maxBitrate: Int, val maxBitrate: Int,
val isAC3Enabled: Boolean, val isAC3Enabled: Boolean,
val downMixAudio: Boolean, val downMixAudio: Boolean,
val assDirectPlay: Boolean, val assPlaybackMode: AssPlaybackMode,
val pgsDirectPlay: Boolean, val pgsDirectPlay: Boolean,
val dolbyVisionELDirectPlay: Boolean, val dolbyVisionELDirectPlay: Boolean,
val decodeAv1: Boolean, val decodeAv1: Boolean,

View file

@ -0,0 +1,59 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import com.github.damontecres.wholphin.BuildConfig
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.displayPreferencesApi
import org.jellyfin.sdk.model.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class DisplayPreferencesService
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient,
) {
private val mutex = Mutex()
suspend fun getDisplayPreferences(
userId: UUID,
displayPreferencesId: String = DEFAULT_DISPLAY_PREF_ID,
client: String = DEFAULT_CLIENT,
) = api.displayPreferencesApi
.getDisplayPreferences(
userId = userId,
displayPreferencesId = displayPreferencesId,
client = client,
).content
suspend fun updateDisplayPreferences(
userId: UUID,
displayPreferencesId: String = DEFAULT_DISPLAY_PREF_ID,
client: String = DEFAULT_CLIENT,
block: MutableMap<String, String?>.() -> Unit,
) {
mutex.withLock {
val current = getDisplayPreferences(userId, DEFAULT_DISPLAY_PREF_ID)
val customPrefs =
current.customPrefs.toMutableMap().apply {
block.invoke(this)
}
api.displayPreferencesApi.updateDisplayPreferences(
displayPreferencesId = displayPreferencesId,
userId = userId,
client = client,
data = current.copy(customPrefs = customPrefs),
)
}
}
companion object {
const val DEFAULT_DISPLAY_PREF_ID = "default"
val DEFAULT_CLIENT = if (BuildConfig.DEBUG) "Wholphin (Debug)" else "Wholphin"
}
}

View file

@ -9,12 +9,20 @@ import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/**
* Get extras for media
*
* @see [ExtrasItem]
*/
@Singleton @Singleton
class ExtrasService class ExtrasService
@Inject @Inject
constructor( constructor(
private val api: ApiClient, private val api: ApiClient,
) { ) {
/**
* Get the [ExtrasItem]s for the given item
*/
suspend fun getExtras(itemId: UUID): List<ExtrasItem> { suspend fun getExtras(itemId: UUID): List<ExtrasItem> {
val extrasMap = val extrasMap =
api.userLibraryApi api.userLibraryApi
@ -42,6 +50,9 @@ class ExtrasService
} }
} }
/**
* The order which extras should be shown
*/
private val ExtraType.sortOrder: Int private val ExtraType.sortOrder: Int
get() = get() =
when (this) { when (this) {

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.services package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.data.model.BaseItem
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.playStateApi import org.jellyfin.sdk.api.client.extensions.playStateApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi
@ -8,6 +9,9 @@ import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/**
* Handles toggling media as favorited or watched
*/
@Singleton @Singleton
class FavoriteWatchManager class FavoriteWatchManager
@Inject @Inject
@ -36,4 +40,7 @@ class FavoriteWatchManager
} else { } else {
api.userLibraryApi.unmarkFavoriteItem(itemId).content api.userLibraryApi.unmarkFavoriteItem(itemId).content
} }
suspend fun removeContinueWatching(item: BaseItem) {
}
} }

View file

@ -8,6 +8,7 @@ import com.github.damontecres.wholphin.data.model.HomePageSettings
import com.github.damontecres.wholphin.data.model.HomeRowConfig import com.github.damontecres.wholphin.data.model.HomeRowConfig
import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION 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.createGenreDestination
import com.github.damontecres.wholphin.data.model.createStudioDestination
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
import com.github.damontecres.wholphin.preferences.HomePagePreferences import com.github.damontecres.wholphin.preferences.HomePagePreferences
import com.github.damontecres.wholphin.ui.DefaultItemFields import com.github.damontecres.wholphin.ui.DefaultItemFields
@ -21,6 +22,7 @@ import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.GetGenresRequestHandler import com.github.damontecres.wholphin.util.GetGenresRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetPersonsHandler import com.github.damontecres.wholphin.util.GetPersonsHandler
import com.github.damontecres.wholphin.util.GetStudiosRequestHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.HomeRowLoadingState.Error import com.github.damontecres.wholphin.util.HomeRowLoadingState.Error
import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success
@ -40,7 +42,6 @@ import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.displayPreferencesApi
import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi
@ -58,11 +59,15 @@ import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
import org.jellyfin.sdk.model.api.request.GetPersonsRequest import org.jellyfin.sdk.model.api.request.GetPersonsRequest
import org.jellyfin.sdk.model.api.request.GetRecommendedProgramsRequest import org.jellyfin.sdk.model.api.request.GetRecommendedProgramsRequest
import org.jellyfin.sdk.model.api.request.GetRecordingsRequest import org.jellyfin.sdk.model.api.request.GetRecordingsRequest
import org.jellyfin.sdk.model.api.request.GetStudiosRequest
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/**
* Handles getting home page settings and data
*/
@Singleton @Singleton
class HomeSettingsService class HomeSettingsService
@Inject @Inject
@ -75,6 +80,7 @@ class HomeSettingsService
private val latestNextUpService: LatestNextUpService, private val latestNextUpService: LatestNextUpService,
private val imageUrlService: ImageUrlService, private val imageUrlService: ImageUrlService,
private val suggestionService: SuggestionService, private val suggestionService: SuggestionService,
private val displayPreferencesService: DisplayPreferencesService,
) { ) {
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)
val jsonParser = val jsonParser =
@ -84,6 +90,9 @@ class HomeSettingsService
allowTrailingComma = true allowTrailingComma = true
} }
/**
* The current home page settings
*/
val currentSettings = MutableStateFlow(HomePageResolvedSettings.EMPTY) val currentSettings = MutableStateFlow(HomePageResolvedSettings.EMPTY)
/** /**
@ -94,19 +103,11 @@ class HomeSettingsService
suspend fun saveToServer( suspend fun saveToServer(
userId: UUID, userId: UUID,
settings: HomePageSettings, settings: HomePageSettings,
displayPreferencesId: String = DISPLAY_PREF_ID, displayPreferencesId: String = DisplayPreferencesService.DEFAULT_DISPLAY_PREF_ID,
) { ) {
val current = getDisplayPreferences(userId, DISPLAY_PREF_ID) displayPreferencesService.updateDisplayPreferences(userId, displayPreferencesId) {
val customPrefs = put(CUSTOM_PREF_ID, jsonParser.encodeToString(settings))
current.customPrefs.toMutableMap().apply { }
put(CUSTOM_PREF_ID, jsonParser.encodeToString(settings))
}
api.displayPreferencesApi.updateDisplayPreferences(
displayPreferencesId = displayPreferencesId,
userId = userId,
client = context.getString(R.string.app_name),
data = current.copy(customPrefs = customPrefs),
)
} }
/** /**
@ -118,24 +119,15 @@ class HomeSettingsService
*/ */
suspend fun loadFromServer( suspend fun loadFromServer(
userId: UUID, userId: UUID,
displayPreferencesId: String = DISPLAY_PREF_ID, displayPreferencesId: String = DisplayPreferencesService.DEFAULT_DISPLAY_PREF_ID,
): HomePageSettings? { ): HomePageSettings? =
val current = getDisplayPreferences(userId, displayPreferencesId) displayPreferencesService
return current.customPrefs[CUSTOM_PREF_ID]?.let { .getDisplayPreferences(userId, displayPreferencesId)
val jsonElement = jsonParser.parseToJsonElement(it) .customPrefs[CUSTOM_PREF_ID]
decode(jsonElement) ?.let {
} val jsonElement = jsonParser.parseToJsonElement(it)
} decode(jsonElement)
}
private suspend fun getDisplayPreferences(
userId: UUID,
displayPreferencesId: String,
) = api.displayPreferencesApi
.getDisplayPreferences(
userId = userId,
displayPreferencesId = displayPreferencesId,
client = context.getString(R.string.app_name),
).content
/** /**
* Computes the filename for locally saved [HomePageSettings] * Computes the filename for locally saved [HomePageSettings]
@ -245,6 +237,9 @@ class HomeSettingsService
currentSettings.update { resolvedSettings } currentSettings.update { resolvedSettings }
} }
/**
* Resolve the settings and set them to be the current settings
*/
suspend fun updateCurrent(settings: HomePageSettings) { suspend fun updateCurrent(settings: HomePageSettings) {
val resolvedRows = val resolvedRows =
settings.rows.mapIndexed { index, config -> settings.rows.mapIndexed { index, config ->
@ -317,14 +312,17 @@ class HomeSettingsService
return HomePageResolvedSettings(rowConfig) return HomePageResolvedSettings(rowConfig)
} }
/**
* Create home page settings from the user's web UI home page settings
*/
suspend fun parseFromWebConfig(userId: UUID): HomePageResolvedSettings? { suspend fun parseFromWebConfig(userId: UUID): HomePageResolvedSettings? {
val customPrefs = val customPrefs =
api.displayPreferencesApi displayPreferencesService
.getDisplayPreferences( .getDisplayPreferences(
displayPreferencesId = "usersettings", displayPreferencesId = "usersettings",
userId = userId, userId = userId,
client = "emby", client = "emby",
).content.customPrefs ).customPrefs
val userDto by api.userApi.getUserById(userId) val userDto by api.userApi.getUserById(userId)
val config = userDto.configuration ?: DefaultUserConfiguration val config = userDto.configuration ?: DefaultUserConfiguration
val libraries = val libraries =
@ -438,10 +436,7 @@ class HomeSettingsService
): HomeRowConfigDisplay = ): HomeRowConfigDisplay =
when (config) { when (config) {
is HomeRowConfig.ByParent -> { is HomeRowConfig.ByParent -> {
val name = val name = getItemName(config.parentId) ?: ""
api.userLibraryApi
.getItem(itemId = config.parentId)
.content.name ?: ""
HomeRowConfigDisplay( HomeRowConfigDisplay(
id, id,
name, name,
@ -466,10 +461,7 @@ class HomeSettingsService
} }
is HomeRowConfig.Genres -> { is HomeRowConfig.Genres -> {
val name = val name = getItemName(config.parentId) ?: ""
api.userLibraryApi
.getItem(itemId = config.parentId)
.content.name ?: ""
HomeRowConfigDisplay( HomeRowConfigDisplay(
id, id,
context.getString(R.string.genres_in, name), context.getString(R.string.genres_in, name),
@ -477,6 +469,15 @@ class HomeSettingsService
) )
} }
is HomeRowConfig.Studios -> {
val name = getItemName(config.parentId) ?: ""
HomeRowConfigDisplay(
id,
context.getString(R.string.studios_in, name),
config,
)
}
is HomeRowConfig.GetItems -> { is HomeRowConfig.GetItems -> {
HomeRowConfigDisplay(id, config.name, config) HomeRowConfigDisplay(id, config.name, config)
} }
@ -490,10 +491,7 @@ class HomeSettingsService
} }
is HomeRowConfig.RecentlyAdded -> { is HomeRowConfig.RecentlyAdded -> {
val name = val name = getItemName(config.parentId) ?: ""
api.userLibraryApi
.getItem(itemId = config.parentId)
.content.name ?: ""
HomeRowConfigDisplay( HomeRowConfigDisplay(
id, id,
context.getString(R.string.recently_added_in, name), context.getString(R.string.recently_added_in, name),
@ -502,10 +500,7 @@ class HomeSettingsService
} }
is HomeRowConfig.RecentlyReleased -> { is HomeRowConfig.RecentlyReleased -> {
val name = val name = getItemName(config.parentId) ?: ""
api.userLibraryApi
.getItem(itemId = config.parentId)
.content.name ?: ""
HomeRowConfigDisplay( HomeRowConfigDisplay(
id, id,
context.getString(R.string.recently_released_in, name), context.getString(R.string.recently_released_in, name),
@ -547,10 +542,7 @@ class HomeSettingsService
} }
is HomeRowConfig.Suggestions -> { is HomeRowConfig.Suggestions -> {
val name = val name = getItemName(config.parentId) ?: ""
api.userLibraryApi
.getItem(itemId = config.parentId)
.content.name ?: ""
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = id, id = id,
title = context.getString(R.string.suggestions_for, name), title = context.getString(R.string.suggestions_for, name),
@ -559,6 +551,16 @@ class HomeSettingsService
} }
} }
private suspend fun getItemName(itemId: UUID): String? =
try {
api.userLibraryApi
.getItem(itemId = itemId)
.content.name
} catch (ex: Exception) {
Timber.e(ex, "Could not get name for %s", itemId)
context.getString(R.string.unknown)
}
/** /**
* Fetch the data from the server for a given [HomeRowConfig] * Fetch the data from the server for a given [HomeRowConfig]
*/ */
@ -585,6 +587,7 @@ class HomeSettingsService
title = context.getString(R.string.continue_watching), title = context.getString(R.string.continue_watching),
items = resume, items = resume,
viewOptions = row.viewOptions, viewOptions = row.viewOptions,
rowType = row,
) )
} }
@ -603,6 +606,7 @@ class HomeSettingsService
title = context.getString(R.string.next_up), title = context.getString(R.string.next_up),
items = nextUp, items = nextUp,
viewOptions = row.viewOptions, viewOptions = row.viewOptions,
rowType = row,
) )
} }
@ -632,6 +636,7 @@ class HomeSettingsService
nextUp, nextUp,
), ),
viewOptions = row.viewOptions, viewOptions = row.viewOptions,
rowType = row,
) )
} }
@ -691,6 +696,58 @@ class HomeSettingsService
title, title,
genres, genres,
viewOptions = row.viewOptions, viewOptions = row.viewOptions,
rowType = row,
)
}
is HomeRowConfig.Studios -> {
val request =
GetStudiosRequest(
parentId = row.parentId,
userId = userDto.id,
limit = limit,
includeItemTypes = listOf(BaseItemKind.SERIES),
)
val items =
GetStudiosRequestHandler
.execute(api, request)
.content.items
val library =
libraries
.firstOrNull { it.itemId == row.parentId }
val title =
library?.name?.let { context.getString(R.string.studios_in, it) }
?: context.getString(R.string.studios)
val studios =
items.map {
val imageUrl =
imageUrlService.getItemImageUrl(
itemId = it.id,
imageType = ImageType.THUMB,
)
BaseItem(
it,
false,
imageUrl,
createStudioDestination(
studioId = it.id,
name = it.name ?: "",
parentId = row.parentId,
parentName = library?.name,
includeItemTypes =
library?.collectionType?.let {
getTypeFor(it)?.let {
listOf(it)
}
},
),
)
}
Success(
title,
studios,
viewOptions = row.viewOptions,
) )
} }
@ -718,6 +775,7 @@ class HomeSettingsService
title, title,
it, it,
row.viewOptions, row.viewOptions,
rowType = row,
) )
} }
latest latest
@ -750,6 +808,7 @@ class HomeSettingsService
title, title,
it, it,
row.viewOptions, row.viewOptions,
rowType = row,
) )
} }
} }
@ -778,6 +837,7 @@ class HomeSettingsService
name ?: context.getString(R.string.collection), name ?: context.getString(R.string.collection),
it, it,
row.viewOptions, row.viewOptions,
rowType = row,
) )
} }
} }
@ -805,6 +865,7 @@ class HomeSettingsService
row.name, row.name,
it, it,
row.viewOptions, row.viewOptions,
rowType = row,
) )
} }
} }
@ -855,6 +916,7 @@ class HomeSettingsService
title, title,
it, it,
row.viewOptions, row.viewOptions,
rowType = row,
) )
} }
} }
@ -879,6 +941,7 @@ class HomeSettingsService
context.getString(R.string.active_recordings), context.getString(R.string.active_recordings),
it, it,
row.viewOptions, row.viewOptions,
rowType = row,
) )
} }
} }
@ -891,7 +954,7 @@ class HomeSettingsService
limit = limit, limit = limit,
enableUserData = true, enableUserData = true,
enableImages = true, enableImages = true,
enableImageTypes = listOf(ImageType.PRIMARY), enableImageTypes = listOf(ImageType.PRIMARY, ImageType.LOGO),
imageTypeLimit = 1, imageTypeLimit = 1,
) )
api.liveTvApi api.liveTvApi
@ -903,6 +966,7 @@ class HomeSettingsService
context.getString(R.string.live_tv), context.getString(R.string.live_tv),
it, it,
row.viewOptions, row.viewOptions,
rowType = row,
) )
} }
} }
@ -920,6 +984,7 @@ class HomeSettingsService
context.getString(R.string.channels), context.getString(R.string.channels),
it, it,
row.viewOptions, row.viewOptions,
rowType = row,
) )
} }
} }
@ -942,12 +1007,14 @@ class HomeSettingsService
title, title,
suggestions.items, suggestions.items,
row.viewOptions, row.viewOptions,
rowType = row,
) )
} else if (suggestions is SuggestionsResource.Empty) { } else if (suggestions is SuggestionsResource.Empty) {
Success( Success(
title, title,
listOf(), listOf(),
row.viewOptions, row.viewOptions,
rowType = row,
) )
} else { } else {
Error( Error(
@ -959,7 +1026,6 @@ class HomeSettingsService
} }
companion object { companion object {
const val DISPLAY_PREF_ID = "default"
const val CUSTOM_PREF_ID = "home_settings" const val CUSTOM_PREF_ID = "home_settings"
} }
} }

View file

@ -0,0 +1,82 @@
package com.github.damontecres.wholphin.services
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
/**
* Gets/saves a serializable object by name
*/
@Singleton
class KeyValueService
@Inject
constructor(
val dataStore: DataStore<Preferences>,
) {
val json =
Json {
ignoreUnknownKeys = true
isLenient = true
encodeDefaults = false
}
inline fun <reified T> get(key: String): Flow<T?> =
dataStore.data.map { preferences ->
preferences[stringPreferencesKey(key)]?.let {
json.decodeFromString<T>(serializer<T>(), it)
}
}
inline fun <reified T> get(
key: String,
defaultValue: T,
): Flow<T> =
dataStore.data.map { preferences ->
preferences[stringPreferencesKey(key)]?.let {
json.decodeFromString<T>(serializer<T>(), it)
} ?: defaultValue
}
inline fun <reified T> get(
userId: UUID,
key: String,
defaultValue: T,
): Flow<T> =
dataStore.data.map { preferences ->
preferences[stringPreferencesKey("${userId}_$key")]?.let {
json.decodeFromString<T>(serializer<T>(), it)
} ?: defaultValue
}
suspend inline fun <reified T> save(
key: String,
value: T,
) {
dataStore.updateData { preferences ->
val valueStr = json.encodeToString(value)
preferences.toMutablePreferences().apply {
set(stringPreferencesKey(key), valueStr)
}
}
}
suspend inline fun <reified T> save(
userId: UUID,
key: String,
value: T,
) {
dataStore.updateData { preferences ->
val valueStr = json.encodeToString(value)
preferences.toMutablePreferences().apply {
set(stringPreferencesKey("${userId}_$key"), valueStr)
}
}
}
}

View file

@ -1,30 +1,32 @@
@file:UseSerializers(
UUIDSerializer::class,
LocalDateTimeSerializer::class,
)
package com.github.damontecres.wholphin.services package com.github.damontecres.wholphin.services
import android.content.Context
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LocalDateTimeSerializer
import com.github.damontecres.wholphin.util.supportItemKinds import com.github.damontecres.wholphin.util.supportItemKinds
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import kotlinx.serialization.json.Json
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.UserDto
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import timber.log.Timber import timber.log.Timber
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.UUID import java.util.UUID
@ -32,14 +34,21 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
/**
* Get continue watching and next up items for users
*/
@Singleton @Singleton
class LatestNextUpService class LatestNextUpService
@Inject @Inject
constructor( constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient, private val api: ApiClient,
private val datePlayedService: DatePlayedService, private val datePlayedService: DatePlayedService,
private val displayPreferencesService: DisplayPreferencesService,
private val favoriteWatchManager: FavoriteWatchManager,
) { ) {
/**
* Get resume (continue watching) items for a user
*/
suspend fun getResume( suspend fun getResume(
userId: UUID, userId: UUID,
limit: Int, limit: Int,
@ -61,12 +70,6 @@ class LatestNextUpService
remove(BaseItemKind.EPISODE) remove(BaseItemKind.EPISODE)
} }
}, },
enableImageTypes =
listOf(
ImageType.PRIMARY,
ImageType.THUMB,
ImageType.BACKDROP,
),
) )
val items = val items =
api.itemsApi api.itemsApi
@ -77,6 +80,9 @@ class LatestNextUpService
return items return items
} }
/**
* Get next up items for a user
*/
suspend fun getNextUp( suspend fun getNextUp(
userId: UUID, userId: UUID,
limit: Int, limit: Int,
@ -85,6 +91,7 @@ class LatestNextUpService
maxDays: Int, maxDays: Int,
useSeriesForPrimary: Boolean = true, useSeriesForPrimary: Boolean = true,
): List<BaseItem> { ): List<BaseItem> {
val removedSeries = getRemovedFromNextUp(userId)
val nextUpDateCutoff = val nextUpDateCutoff =
maxDays.takeIf { it > 0 }?.let { LocalDateTime.now().minusDays(it.toLong()) } maxDays.takeIf { it > 0 }?.let { LocalDateTime.now().minusDays(it.toLong()) }
val request = val request =
@ -105,68 +112,31 @@ class LatestNextUpService
.content .content
.items .items
.map { BaseItem.from(it, api, useSeriesForPrimary) } .map { BaseItem.from(it, api, useSeriesForPrimary) }
.filter {
val seriesId = it.data.seriesId
if (seriesId != null && seriesId in removedSeries) {
// User has previously removed the series
val lastPlayedDate = it.data.userData?.lastPlayedDate
if (lastPlayedDate != null) {
// If item played it after it was removed, should include it
lastPlayedDate > removedSeries[seriesId]
} else {
// If unknown last played, filter out
false
}
} else {
true
}
}
return nextUp return nextUp
} }
suspend fun getLatest( /**
user: UserDto, * Create the combined Continue Watching & Next Up items
limit: Int, *
includedIds: List<UUID>, * @see [DatePlayedService]
): List<LatestData> { */
val excluded = user.configuration?.latestItemsExcludes.orEmpty()
val views by api.userViewsApi.getUserViews()
val latestData =
views.items
.filter {
it.id in includedIds && it.id !in excluded &&
it.collectionType in supportedLatestCollectionTypes
}.map { view ->
val title =
view.name?.let { context.getString(R.string.recently_added_in, it) }
?: context.getString(R.string.recently_added)
val request =
GetLatestMediaRequest(
fields = SlimItemFields,
imageTypeLimit = 1,
parentId = view.id,
groupItems = true,
limit = limit,
isPlayed = null, // Server will handle user's preference
)
LatestData(title, request)
}
return latestData
}
suspend fun loadLatest(latestData: List<LatestData>): List<HomeRowLoadingState> {
val rows =
latestData.mapNotNull { (title, request) ->
try {
val latest =
api.userLibraryApi
.getLatestMedia(request)
.content
.map { BaseItem.from(it, api, true) }
if (latest.isNotEmpty()) {
HomeRowLoadingState.Success(
title = title,
items = latest,
)
} else {
null
}
} catch (ex: Exception) {
Timber.e(ex, "Exception fetching %s", title)
HomeRowLoadingState.Error(
title = title,
exception = ex,
)
}
}
return rows
}
suspend fun buildCombined( suspend fun buildCombined(
resume: List<BaseItem>, resume: List<BaseItem>,
nextUp: List<BaseItem>, nextUp: List<BaseItem>,
@ -199,18 +169,112 @@ class LatestNextUpService
Timber.v("buildCombined took %s", duration) Timber.v("buildCombined took %s", duration)
return@withContext result return@withContext result
} }
/**
* Remove a series from next up
*/
suspend fun removeFromNextUp(
userId: UUID,
episode: BaseItem,
) {
favoriteWatchManager.setWatched(episode.id, false)
episode.data.seriesId?.let { seriesId ->
displayPreferencesService.updateDisplayPreferences(userId) {
val removedIds =
get(REMOVED_KEY)
?.let {
Json.decodeFromString<RemovedSeriesIds>(it).value
}.orEmpty()
.toMutableMap()
removedIds[seriesId] = LocalDateTime.now()
put(
REMOVED_KEY,
Json.encodeToString(RemovedSeriesIds(removedIds)),
)
}
}
}
/**
* Get when series were removed from next up
*/
suspend fun getRemovedFromNextUp(userId: UUID): Map<UUID, LocalDateTime> =
displayPreferencesService
.getDisplayPreferences(userId)
.customPrefs[REMOVED_KEY]
?.let {
Json.decodeFromString<RemovedSeriesIds>(it).value
}.orEmpty()
suspend fun allowSeriesRemovedFromNextUp(
userId: UUID,
seriesId: UUID,
) {
displayPreferencesService.updateDisplayPreferences(userId) {
val ids =
get(REMOVED_KEY)
?.let {
Json.decodeFromString<RemovedSeriesIds>(it).value
}.orEmpty()
.toMutableMap()
ids.remove(seriesId)
put(
REMOVED_KEY,
Json.encodeToString(RemovedSeriesIds(ids)),
)
}
}
/**
* Check if user has watched a series since removing it
*/
suspend fun updateRemovedFromNextUp(userId: UUID) {
val removed = getRemovedFromNextUp(userId)
val newRemoved = removed.toMutableMap()
var changed = false
removed.forEach { (seriesId, timestamp) ->
val item =
api.itemsApi
.getItems(
userId = userId,
parentId = seriesId,
recursive = true,
includeItemTypes = listOf(BaseItemKind.EPISODE),
sortBy = listOf(ItemSortBy.DATE_PLAYED),
sortOrder = listOf(SortOrder.DESCENDING),
limit = 1,
).content.items
.firstOrNull()
if (item != null) {
val lastPlayed = item.userData?.lastPlayedDate
if (lastPlayed != null && lastPlayed > timestamp) {
Timber.v("Updating removed next up for series %s", seriesId)
newRemoved.remove(seriesId)
changed = true
}
} else {
// Series doesn't exist anymore
Timber.v("Updating removed next up for missing series %s", seriesId)
newRemoved.remove(seriesId)
changed = true
}
}
if (changed) {
displayPreferencesService.updateDisplayPreferences(userId) {
put(
REMOVED_KEY,
Json.encodeToString(RemovedSeriesIds(newRemoved)),
)
}
}
}
companion object {
const val REMOVED_KEY = "removeNextUp"
}
} }
val supportedLatestCollectionTypes = @Serializable
setOf( data class RemovedSeriesIds(
CollectionType.MOVIES, val value: Map<UUID, LocalDateTime>,
CollectionType.TVSHOWS,
CollectionType.HOMEVIDEOS,
// Exclude Live TV because a recording folder view will be used instead
null, // Recordings & mixed collection types
)
data class LatestData(
val title: String,
val request: GetLatestMediaRequest,
) )

View file

@ -0,0 +1,139 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import androidx.appcompat.app.AppCompatActivity
import androidx.hilt.work.HiltWorker
import androidx.lifecycle.lifecycleScope
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.util.ExceptionHandler
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
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.toJavaDuration
@HiltWorker
class LatestNextUpWorker
@AssistedInject
constructor(
@Assisted private val context: Context,
@Assisted workerParams: WorkerParameters,
private val serverRepository: ServerRepository,
private val api: ApiClient,
private val latestNextUpService: LatestNextUpService,
) : CoroutineWorker(context, workerParams) {
override suspend fun doWork(): Result {
Timber.d("Start")
val serverId =
inputData.getString(PARAM_SERVER_ID)?.toUUIDOrNull() ?: return Result.failure()
val userId =
inputData.getString(PARAM_USER_ID)?.toUUIDOrNull() ?: return Result.failure()
try {
if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) {
// Not active
var currentUser = serverRepository.current.value
if (currentUser == null) {
serverRepository.restoreSession(serverId, userId)
currentUser = serverRepository.current.value
}
if (currentUser == null) {
Timber.w("No user found during run")
return Result.failure()
}
}
latestNextUpService.updateRemovedFromNextUp(userId)
return Result.success()
} catch (ex: Exception) {
Timber.e(ex, "Error during updateRemovedFromNextUp")
return Result.retry()
}
}
companion object {
const val WORK_NAME = "com.github.damontecres.wholphin.services.LatestNextUpWorker"
const val PARAM_USER_ID = "userId"
const val PARAM_SERVER_ID = "serverId"
}
}
@ActivityScoped
class LatestNextUpSchedulerService
@Inject
constructor(
@param:ActivityContext private val context: Context,
private val serverRepository: ServerRepository,
private val workManager: WorkManager,
) {
private val activity =
(context as? AppCompatActivity)
?: throw IllegalStateException(
"SuggestionsSchedulerService requires an AppCompatActivity context, but received: ${context::class.java.name}",
)
// Exposed for testing
internal var dispatcher: CoroutineDispatcher = Dispatchers.IO
init {
serverRepository.current.observe(activity) { user ->
Timber.v("New user %s", user?.user?.id)
if (user == null) {
workManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME)
} else {
activity.lifecycleScope.launch(dispatcher + ExceptionHandler()) {
scheduleWork(user.user.id, user.server.id)
}
}
}
}
private suspend fun scheduleWork(
userId: UUID,
serverId: UUID,
) {
val constraints =
Constraints
.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val periodicWorkRequestBuilder =
PeriodicWorkRequestBuilder<LatestNextUpWorker>(
repeatInterval = 4.hours.toJavaDuration(),
).setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
15.minutes.toJavaDuration(),
).setConstraints(constraints)
.setInputData(
workDataOf(
LatestNextUpWorker.PARAM_USER_ID to userId.toString(),
LatestNextUpWorker.PARAM_SERVER_ID to serverId.toString(),
),
)
Timber.i("Scheduling periodic LatestNextUpWorker")
workManager.enqueueUniquePeriodicWork(
uniqueWorkName = LatestNextUpWorker.WORK_NAME,
existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.REPLACE,
request = periodicWorkRequestBuilder.build(),
)
}
}

View file

@ -19,6 +19,9 @@ import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/**
* Service to manage media such as deletions
*/
@Singleton @Singleton
class MediaManagementService class MediaManagementService
@Inject @Inject
@ -45,6 +48,9 @@ class MediaManagementService
return canDelete(item, appPreferences) return canDelete(item, appPreferences)
} }
/**
* Check if the item can be deleted. This means the app setting is enabled and the user has permission.
*/
fun canDelete( fun canDelete(
item: BaseItem, item: BaseItem,
appPreferences: AppPreferences, appPreferences: AppPreferences,
@ -62,10 +68,14 @@ class MediaManagementService
} }
} }
/**
* Delete the item.
*
* This item will be sent through [deletedItemFlow] for other services or view models to react.
*/
suspend fun deleteItem(item: BaseItem): DeleteResult { suspend fun deleteItem(item: BaseItem): DeleteResult {
try { try {
Timber.i("Deleting %s", item.id) Timber.i("Deleting %s", item.id)
// TODO enable
api.libraryApi.deleteItem(item.id) api.libraryApi.deleteItem(item.id)
_deletedItemFlow.emit(DeletedItem(item)) _deletedItemFlow.emit(DeletedItem(item))
return DeleteResult.Success return DeleteResult.Success
@ -88,6 +98,9 @@ sealed interface DeleteResult {
) : DeleteResult ) : DeleteResult
} }
/**
* Convenience function to delete an item and show a Toast based on success or error
*/
fun ViewModel.deleteItem( fun ViewModel.deleteItem(
context: Context, context: Context,
mediaManagementService: MediaManagementService, mediaManagementService: MediaManagementService,

View file

@ -21,6 +21,9 @@ import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/**
* Send media info to the server
*/
@Singleton @Singleton
class MediaReportService class MediaReportService
@Inject @Inject
@ -39,6 +42,9 @@ class MediaReportService
encodeDefaults = false encodeDefaults = false
} }
/**
* Fetch the media info and send it to the server
*/
fun sendReportFor(itemId: UUID) { fun sendReportFor(itemId: UUID) {
ioScope.launchIO(ExceptionHandler(autoToast = true)) { ioScope.launchIO(ExceptionHandler(autoToast = true)) {
val item = api.userLibraryApi.getItem(itemId = itemId).content val item = api.userLibraryApi.getItem(itemId = itemId).content
@ -46,6 +52,9 @@ class MediaReportService
} }
} }
/**
* Send the media report for the given item
*/
suspend fun sendReportFor(item: BaseItemDto) { suspend fun sendReportFor(item: BaseItemDto) {
val sources = val sources =
item.mediaSources ?: api.userLibraryApi item.mediaSources ?: api.userLibraryApi

View file

@ -0,0 +1,551 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import androidx.annotation.OptIn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.Timeline
import androidx.media3.common.util.UnstableApi
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.services.hilt.DefaultCoroutineScope
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
import com.github.damontecres.wholphin.ui.seekForward
import com.github.damontecres.wholphin.ui.toServerString
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.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
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.instantMixApi
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.PlayMethod
import org.jellyfin.sdk.model.api.PlaystateCommand
import org.jellyfin.sdk.model.api.PlaystateMessage
import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUID
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.seconds
/**
* Manage the global state for playing music
*
* Has functions for modifying the queue
*/
@OptIn(UnstableApi::class)
@Singleton
class MusicService
@Inject
constructor(
@param:ApplicationContext private val context: Context,
@param:DefaultCoroutineScope private val defaultScope: CoroutineScope,
private val api: ApiClient,
private val playerFactory: PlayerFactory,
private val serverRepository: ServerRepository,
private val imageUrlService: ImageUrlService,
) {
private val _state = MutableStateFlow(MusicServiceState.EMPTY)
val state: StateFlow<MusicServiceState> = _state
private val audioFormats by lazy { listOf(*supportedAudioCodecs) }
val player: Player by lazy {
playerFactory
.createAudioPlayer()
.also {
it.addListener(MusicPlayerListener(it, _state))
}
}
private val mutex = Mutex()
var mediaSession: MediaSession? = null
private set
private var activityTracker: TrackActivityPlaybackListener? = null
private var websocketJob: Job? = null
/**
* Start music playback
*
* Sets up the media session, activity tracking, and actual playback
*/
suspend fun start() {
if (mediaSession == null) {
mutex.withLock {
if (mediaSession == null) {
Timber.i("Starting music MediaSession")
mediaSession = MediaSession.Builder(context, player).build()
activityTracker =
TrackActivityPlaybackListener(api, player) {
state.value.currentItemId?.let { itemId ->
PlaybackItemState(
itemId = itemId,
playMethod = PlayMethod.DIRECT_PLAY,
// playSessionId = mediaSession?.id,
)
}
}.also { player.addListener(it) }
websocketJob = subscribe()
}
}
}
onMain {
player.prepare()
player.play()
}
}
/**
* Stop music playback
*/
suspend fun stop() {
mutex.withLock {
Timber.i("Stopping music")
if (mediaSession == null) {
Timber.w("Stopping but no MediaSession")
}
mediaSession?.release()
mediaSession = null
onMain {
websocketJob?.cancel()
websocketJob = null
activityTracker?.let {
it.release()
player.removeListener(it)
}
activityTracker = null
player.stop()
player.setMediaItems(emptyList())
}
_state.update {
MusicServiceState.EMPTY
}
}
}
/**
* Fetches instant mix items, replaces the queue, and begins playback
*/
suspend fun startInstantMix(itemId: UUID) =
loading {
val items =
api.instantMixApi
.getInstantMixFromItem(
userId = serverRepository.currentUser.value?.id,
itemId = itemId,
limit = 200,
fields = DefaultItemFields,
).content.items
.map { BaseItem(it, false) }
setQueue(items, false)
}
/**
* Replace the queue with the given list and starting playing the song as startIndex as soon as its ready
*
* Fetches each item in a blocking way and adds to the queue
*/
suspend fun setQueue(
items: BlockingList<BaseItem?>,
startIndex: Int,
shuffled: Boolean,
) = withContext(Dispatchers.IO) {
Timber.d("setQueue: %s items, startIndex=%s, shuffled=%s", items.size, startIndex, shuffled)
withContext(Dispatchers.Main) {
player.setMediaItems(emptyList())
player.shuffleModeEnabled = shuffled
}
start()
addAllToQueue(items, startIndex)
}
/**
* Replace the queue with the given items
*/
suspend fun setQueue(
items: List<BaseItem>,
shuffled: Boolean,
) {
Timber.d("setQueue: %s items, shuffled=%s", items.size, shuffled)
val mediaItems =
items
.filter { it.type == BaseItemKind.AUDIO }
.map(::convert)
withContext(Dispatchers.Main) {
player.setMediaItems(mediaItems)
player.shuffleModeEnabled = shuffled
updateQueueSize()
start()
}
}
/**
* Add an item to the specified index of the queue. If no index specified, it will be added to the end.
*/
suspend fun addToQueue(
item: BaseItem,
index: Int? = null,
) {
if (item.type == BaseItemKind.AUDIO) {
val mediaItem = convert(item)
withContext(Dispatchers.Main) {
if (index != null) {
player.addMediaItem(index, mediaItem)
} else {
player.addMediaItem(mediaItem)
}
updateQueueSize()
if (player.mediaItemCount == 1) {
// Start playing if this was the first time added
start()
}
}
}
}
/**
* Add all the items in teh list to end of the queue
*
* @param startIndex The index to start from within the source list
*/
suspend fun addAllToQueue(
list: BlockingList<BaseItem?>,
startIndex: Int,
) = loading {
var remaining = startIndex
list.indices
.chunked(25)
.forEach {
val mediaItems =
it.mapNotNull {
if (remaining == 0) {
list
.getBlocking(it)
?.takeIf { it.type == BaseItemKind.AUDIO }
?.let(::convert)
} else {
Timber.v("Skipping $remaining")
remaining--
null
}
}
onMain { player.addMediaItems(mediaItems) }
}
updateQueueSize()
start()
}
/**
* Converts a [BaseItem] into a [MediaItem] setting an [AudioItem] as its tag
*/
private fun convert(audio: BaseItem): MediaItem {
val url =
api.universalAudioApi.getUniversalAudioStreamUrl(
itemId = audio.id,
container = audioFormats,
)
Timber.i("url=%s", url)
val imageUrl =
audio.data.albumId?.let { albumId ->
imageUrlService.getItemImageUrl(
itemId = albumId,
imageType = ImageType.PRIMARY,
)
}
return MediaItem
.Builder()
.setUri(url)
.setMediaId(audio.id.toServerString())
.setTag(AudioItem.from(audio, imageUrl))
.build()
}
/**
* Updates the state for when the queue changes
*/
private suspend fun updateQueueSize() {
// val ids =
// withContext(Dispatchers.Default) {
// (0..<player.mediaItemCount).map { player.getMediaItemAt(it).mediaId.toUUID() }
// }
val timeline = onMain { player.currentTimeline }
val window = Timeline.Window()
val ids =
(0..<timeline.windowCount)
.map {
timeline.getWindow(it, window)
window.mediaItem.mediaId.toUUID()
}.toSet()
withContext(Dispatchers.Main) {
_state.update {
it.copy(
queueVersion = it.queueVersion + 1,
queueSize = player.mediaItemCount,
queuedIds = ids,
)
}
}
}
/**
* Move an item within the queue
*/
suspend fun moveQueue(
index: Int,
direction: MoveDirection,
) = withContext(Dispatchers.Main) {
player.moveMediaItem(index, if (direction == MoveDirection.UP) index - 1 else index + 1)
updateQueueSize()
}
/**
* Move an item within the queue
*/
suspend fun moveQueue(
index: Int,
newIndex: Int,
) = withContext(Dispatchers.Main) {
player.moveMediaItem(index, newIndex)
updateQueueSize()
}
/**
* Start playback at the given index of the queue
*/
suspend fun playIndex(index: Int) {
onMain {
player.seekTo(index, 0L)
player.play()
}
// MusicPlayerListener will update state
}
/**
* 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)
onMain {
player.addMediaItem(state.value.currentIndex + 1, mediaItem)
if (player.mediaItemCount == 1) {
start()
}
}
updateQueueSize()
}
/**
* From the item at the given index from the queue
*/
suspend fun removeFromQueue(index: Int) {
onMain { player.removeMediaItem(index) }
updateQueueSize()
}
private suspend fun <T> loading(block: suspend () -> T): T {
_state.update { it.copy(loadingState = LoadingState.Loading) }
val result = block.invoke()
_state.update { it.copy(loadingState = LoadingState.Success) }
return result
}
/**
* Subscribes to the server websocket to receive playback commands
*/
private fun subscribe(): Job =
api.webSocket
.subscribe<PlaystateMessage>()
.onEach { message ->
message.data?.let {
withContext(Dispatchers.Main) {
when (it.command) {
PlaystateCommand.STOP -> {
stop()
}
PlaystateCommand.PAUSE -> {
player.pause()
}
PlaystateCommand.UNPAUSE -> {
player.play()
}
PlaystateCommand.NEXT_TRACK -> {
player.seekToNext()
}
PlaystateCommand.PREVIOUS_TRACK -> {
player.seekToPrevious()
}
PlaystateCommand.SEEK -> {
it.seekPositionTicks?.ticks?.let {
player.seekTo(
it.inWholeMilliseconds,
)
}
}
PlaystateCommand.REWIND -> {
player.seekBack(10.seconds)
}
PlaystateCommand.FAST_FORWARD -> {
player.seekForward(30.seconds)
}
PlaystateCommand.PLAY_PAUSE -> {
if (player.isPlaying) player.pause() else player.play()
}
}
}
}
}.catch { ex ->
Timber.e(ex, "Error in websocket subscription")
}.launchIn(defaultScope)
}
@Stable
data class MusicServiceState(
val queueVersion: Long,
val queueSize: Int,
val currentIndex: Int,
val currentItemId: UUID?,
val status: NowPlayingStatus,
val currentItemTitle: String?,
val loadingState: LoadingState = LoadingState.Pending,
val queuedIds: Set<UUID>,
) {
companion object {
val EMPTY =
MusicServiceState(
0L,
0,
0,
null,
NowPlayingStatus.IDLE,
null,
LoadingState.Pending,
emptySet(),
)
}
}
enum class NowPlayingStatus {
PLAYING,
PAUSED,
IDLE,
}
/**
* Listens to [Player] events and updates the [StateFlow]
*/
private class MusicPlayerListener(
private val player: Player,
private val state: MutableStateFlow<MusicServiceState>,
) : Player.Listener {
init {
Timber.v("MusicPlayerListener init")
state.update {
it.copy(
queueSize = player.mediaItemCount,
currentIndex = player.currentMediaItemIndex,
status = if (player.isPlaying) NowPlayingStatus.PLAYING else NowPlayingStatus.IDLE,
)
}
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
Timber.v("MusicPlayerListener onIsPlayingChanged")
state.update {
it.copy(
status = if (isPlaying) NowPlayingStatus.PLAYING else NowPlayingStatus.PAUSED,
)
}
}
override fun onMediaItemTransition(
mediaItem: MediaItem?,
reason: Int,
) {
Timber.v("MusicPlayerListener onMediaItemTransition")
updateCurrentIndex()
}
override fun onTimelineChanged(
timeline: Timeline,
reason: Int,
) {
// Timber.v("MusicPlayerListener onTimelineChanged")
if (reason == Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) {
updateCurrentIndex()
}
}
private fun updateCurrentIndex() {
state.update { state ->
player.currentMediaItemIndex.takeIf { it >= 0 }?.let { currentMediaItemIndex ->
if (currentMediaItemIndex in (0..<player.mediaItemCount)) {
val item =
player.getMediaItemAt(currentMediaItemIndex).localConfiguration?.tag as? AudioItem
state.copy(
currentIndex = currentMediaItemIndex,
currentItemId = player.getMediaItemAt(currentMediaItemIndex).mediaId.toUUIDOrNull(),
currentItemTitle = item?.title,
)
} else {
state
}
} ?: state
}
}
}
/**
* Remember the queue currently playing
*
* @see MusicServiceState
*/
@Composable
fun rememberQueue(
player: Player,
queueVersion: Long,
queueSize: Int,
): List<AudioItem> =
remember(queueVersion, queueSize) {
object : AbstractList<AudioItem>() {
override val size: Int
get() = player.mediaItemCount
override fun get(index: Int): AudioItem = player.getMediaItemAt(index).localConfiguration?.tag as AudioItem
}
}

View file

@ -7,6 +7,7 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.NavPinType import com.github.damontecres.wholphin.data.model.NavPinType
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.main.settings.Library import com.github.damontecres.wholphin.ui.main.settings.Library
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
@ -16,9 +17,11 @@ import com.github.damontecres.wholphin.util.supportedCollectionTypes
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@ -33,7 +36,11 @@ import timber.log.Timber
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.time.Duration.Companion.hours
/**
* Gets the items to show in the nav drawer
*/
@Singleton @Singleton
class NavDrawerService class NavDrawerService
@Inject @Inject
@ -44,11 +51,13 @@ class NavDrawerService
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val serverPreferencesDao: ServerPreferencesDao, private val serverPreferencesDao: ServerPreferencesDao,
private val seerrServerRepository: SeerrServerRepository, private val seerrServerRepository: SeerrServerRepository,
private val musicService: MusicService,
) { ) {
private val _state = MutableStateFlow(NavDrawerItemState.EMPTY) private val _state = MutableStateFlow(NavDrawerItemState.EMPTY)
val state: StateFlow<NavDrawerItemState> = _state val state: StateFlow<NavDrawerItemState> = _state
init { init {
// Handle updating the nav drawer when the user changes
serverRepository.currentUser serverRepository.currentUser
.asFlow() .asFlow()
.combine(serverRepository.currentUserDto.asFlow()) { user, userDto -> .combine(serverRepository.currentUserDto.asFlow()) { user, userDto ->
@ -69,12 +78,52 @@ class NavDrawerService
showToast(context, "Error fetching user's views") showToast(context, "Error fetching user's views")
}.launchIn(coroutineScope) }.launchIn(coroutineScope)
// Handle when the user has logged into a Seerr server
seerrServerRepository.active seerrServerRepository.active
.onEach { discoverActive -> .onEach { discoverActive ->
_state.update { it.copy(discoverEnabled = discoverActive) } _state.update { it.copy(discoverEnabled = discoverActive) }
}.launchIn(coroutineScope) }.launchIn(coroutineScope)
// Handle when music is actively playing or not
coroutineScope.launchDefault {
musicService.state.collectLatest { music ->
Timber.v("MusicService updated")
when (music.status) {
NowPlayingStatus.PLAYING -> {
_state.update {
it.copy(
nowPlayingEnabled = true,
nowPlayingTitle = music.currentItemTitle,
)
}
}
NowPlayingStatus.IDLE -> {
_state.update {
it.copy(
nowPlayingEnabled = false,
nowPlayingTitle = null,
)
}
}
NowPlayingStatus.PAUSED -> {
delay(2.hours)
_state.update {
it.copy(
nowPlayingEnabled = false,
nowPlayingTitle = null,
)
}
}
}
}
}
} }
/**
* Get all the libraries the user has access to
*/
suspend fun getAllUserLibraries( suspend fun getAllUserLibraries(
userId: UUID, userId: UUID,
tvAccess: Boolean, tvAccess: Boolean,
@ -108,6 +157,9 @@ class NavDrawerService
return libraries return libraries
} }
/**
* Get the libraries that the user has not "pinned". These will show in the More section.
*/
suspend fun getFilteredUserLibraries( suspend fun getFilteredUserLibraries(
user: JellyfinUser, user: JellyfinUser,
tvAccess: Boolean, tvAccess: Boolean,
@ -122,6 +174,9 @@ class NavDrawerService
return libraries return libraries
} }
/**
* Update the current state of the nav drawer items
*/
suspend fun updateNavDrawer( suspend fun updateNavDrawer(
user: JellyfinUser, user: JellyfinUser,
userDto: UserDto, userDto: UserDto,
@ -185,9 +240,11 @@ data class NavDrawerItemState(
val items: List<NavDrawerItem>, val items: List<NavDrawerItem>,
val moreItems: List<NavDrawerItem>, val moreItems: List<NavDrawerItem>,
val discoverEnabled: Boolean, val discoverEnabled: Boolean,
val nowPlayingEnabled: Boolean,
val nowPlayingTitle: String?,
) { ) {
companion object { companion object {
val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false) val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false, false, null)
} }
} }

View file

@ -1,6 +1,6 @@
package com.github.damontecres.wholphin.services package com.github.damontecres.wholphin.services
import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.NavBackStack
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import org.acra.ACRA import org.acra.ACRA
import timber.log.Timber import timber.log.Timber
@ -14,7 +14,7 @@ import javax.inject.Singleton
class NavigationManager class NavigationManager
@Inject @Inject
constructor() { constructor() {
var backStack: MutableList<NavKey> = mutableListOf() var backStack: MutableList<Destination> = NavBackStack(Destination.Home())
/** /**
* Go to the specified [com.github.damontecres.wholphin.ui.nav.Destination] * Go to the specified [com.github.damontecres.wholphin.ui.nav.Destination]

View file

@ -10,6 +10,9 @@ import org.jellyfin.sdk.api.client.extensions.itemsApi
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/**
* Gets people in media, specifically to check if they are favorited or not
*/
@Singleton @Singleton
class PeopleFavorites class PeopleFavorites
@Inject @Inject

View file

@ -2,7 +2,6 @@ package com.github.damontecres.wholphin.services
import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import com.github.damontecres.wholphin.ui.nav.Destination
import dagger.hilt.android.scopes.ActivityRetainedScoped import dagger.hilt.android.scopes.ActivityRetainedScoped
import javax.inject.Inject import javax.inject.Inject
@ -20,13 +19,14 @@ class PlaybackLifecycleObserver
private var wasPlaying: Boolean? = null private var wasPlaying: Boolean? = null
override fun onStart(owner: LifecycleOwner) { override fun onStart(owner: LifecycleOwner) {
val lastDest = navigationManager.backStack.lastOrNull() // TODO
if (lastDest is Destination.Playback || // val lastDest = navigationManager.backStack.lastOrNull()
lastDest is Destination.PlaybackList || // if (lastDest is Destination.Playback ||
lastDest is Destination.Slideshow // lastDest is Destination.PlaybackList ||
) { // lastDest is Destination.Slideshow
navigationManager.goBack() // ) {
} // navigationManager.goBack()
// }
wasPlaying = null wasPlaying = null
} }

View file

@ -6,27 +6,39 @@ import android.content.Context
import android.os.Build import android.os.Build
import android.os.Handler import android.os.Handler
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.datastore.core.DataStore import androidx.media3.common.AudioAttributes
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionParameters.AudioOffloadPreferences
import androidx.media3.common.util.ExperimentalApi
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.Renderer import androidx.media3.exoplayer.Renderer
import androidx.media3.exoplayer.RenderersFactory
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.exoplayer.video.MediaCodecVideoRenderer import androidx.media3.exoplayer.video.MediaCodecVideoRenderer
import androidx.media3.exoplayer.video.VideoRendererEventListener import androidx.media3.exoplayer.video.VideoRendererEventListener
import com.github.damontecres.wholphin.preferences.AppPreference import androidx.media3.extractor.DefaultExtractorsFactory
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.MediaExtensionStatus
import com.github.damontecres.wholphin.preferences.PlaybackPreferences import com.github.damontecres.wholphin.preferences.PlaybackPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.util.mpv.MpvPlayer import com.github.damontecres.wholphin.util.mpv.MpvPlayer
import dagger.hilt.android.qualifiers.ApplicationContext 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.Dispatchers
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import timber.log.Timber import timber.log.Timber
import java.lang.reflect.Constructor import java.lang.reflect.Constructor
import javax.inject.Inject import javax.inject.Inject
@ -40,77 +52,23 @@ class PlayerFactory
@Inject @Inject
constructor( constructor(
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
private val appPreferences: DataStore<AppPreferences>, @param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient,
) { ) {
@Volatile @Volatile
var currentPlayer: Player? = null var currentPlayer: Player? = null
private set private set
fun createVideoPlayer(): Player {
if (currentPlayer?.isReleased == false) {
Timber.w("Player was not released before trying to create a new one!")
currentPlayer?.release()
}
val prefs = runBlocking { appPreferences.data.firstOrNull()?.playbackPreferences }
val backend = prefs?.playerBackend ?: AppPreference.PlayerBackendPref.defaultValue
val newPlayer =
when (backend) {
PlayerBackend.PREFER_MPV,
PlayerBackend.MPV,
-> {
val enableHardwareDecoding =
prefs?.mpvOptions?.enableHardwareDecoding
?: AppPreference.MpvHardwareDecoding.defaultValue
val useGpuNext =
prefs?.mpvOptions?.useGpuNext
?: AppPreference.MpvGpuNext.defaultValue
MpvPlayer(context, enableHardwareDecoding, useGpuNext)
.apply {
playWhenReady = true
}
}
PlayerBackend.EXO_PLAYER,
PlayerBackend.UNRECOGNIZED,
-> {
val extensions = prefs?.overrides?.mediaExtensionsEnabled
val decodeAv1 = prefs?.overrides?.decodeAv1 == true
Timber.v("extensions=$extensions")
val rendererMode =
when (extensions) {
MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
MediaExtensionStatus.MES_PREFERRED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF
else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
}
ExoPlayer
.Builder(context)
.setRenderersFactory(
WholphinRenderersFactory(context, decodeAv1)
.setEnableDecoderFallback(true)
.setExtensionRendererMode(rendererMode),
).build()
.apply {
playWhenReady = true
}
}
}
currentPlayer = newPlayer
return newPlayer
}
suspend fun createVideoPlayer( suspend fun createVideoPlayer(
backend: PlayerBackend, backend: PlayerBackend,
prefs: PlaybackPreferences, prefs: PlaybackPreferences,
): Player { ): PlayerCreation {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (currentPlayer?.isReleased == false) { if (currentPlayer?.isReleased == false) {
Timber.w("Player was not released before trying to create a new one!") Timber.w("Player was not released before trying to create a new one!")
currentPlayer?.release() currentPlayer?.release()
} }
} }
var assHandler: AssHandler? = null
val newPlayer = val newPlayer =
when (backend) { when (backend) {
PlayerBackend.PREFER_MPV, PlayerBackend.PREFER_MPV,
@ -125,8 +83,14 @@ class PlayerFactory
PlayerBackend.UNRECOGNIZED, PlayerBackend.UNRECOGNIZED,
-> { -> {
val extensions = prefs.overrides.mediaExtensionsEnabled val extensions = prefs.overrides.mediaExtensionsEnabled
val useLibAss =
prefs.overrides.assPlaybackMode == AssPlaybackMode.ASS_LIBASS
val decodeAv1 = prefs.overrides.decodeAv1 val decodeAv1 = prefs.overrides.decodeAv1
Timber.v("extensions=$extensions") Timber.v(
"extensions=%s, assPlaybackMode=%s",
extensions,
prefs.overrides.assPlaybackMode,
)
val rendererMode = val rendererMode =
when (extensions) { when (extensions) {
MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
@ -134,18 +98,113 @@ class PlayerFactory
MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF
else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
} }
val dataSourceFactory = DefaultDataSource.Factory(context)
val extractorsFactory = createExtractorsFactory()
var renderersFactory: RenderersFactory =
WholphinRenderersFactory(context, decodeAv1)
.setEnableDecoderFallback(true)
.setExtensionRendererMode(rendererMode)
val mediaSourceFactory =
if (useLibAss) {
assHandler = AssHandler(AssRenderType.OVERLAY_OPEN_GL)
val assSubtitleParserFactory = AssSubtitleParserFactory(assHandler)
renderersFactory = AssRenderersFactory(assHandler, renderersFactory)
DefaultMediaSourceFactory(
dataSourceFactory,
extractorsFactory.withAssMkvSupport(
assSubtitleParserFactory,
assHandler,
),
).setSubtitleParserFactory(assSubtitleParserFactory)
} else {
DefaultMediaSourceFactory(
dataSourceFactory,
extractorsFactory,
)
}
val trackSelector = createTrackSelector()
ExoPlayer ExoPlayer
.Builder(context) .Builder(context)
.setRenderersFactory( .setMediaSourceFactory(mediaSourceFactory)
WholphinRenderersFactory(context, decodeAv1) .setRenderersFactory(renderersFactory)
.setEnableDecoderFallback(true) .setTrackSelector(trackSelector)
.setExtensionRendererMode(rendererMode), .build()
).build() .apply {
assHandler?.init(this)
withContext(Dispatchers.Main) {
setAudioAttributes(
AudioAttributes
.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.build(),
false,
)
}
}
}
PlayerBackend.EXTERNAL_PLAYER -> {
throw IllegalArgumentException("Cannot create a player for external playback")
} }
} }
currentPlayer = newPlayer currentPlayer = newPlayer
return newPlayer return PlayerCreation(newPlayer, assHandler)
} }
fun createAudioPlayer(extensions: MediaExtensionStatus = MediaExtensionStatus.MES_FALLBACK): ExoPlayer {
val rendererMode =
when (extensions) {
MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
MediaExtensionStatus.MES_PREFERRED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF
else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
}
val extractorsFactory = createExtractorsFactory()
val renderersFactory: RenderersFactory =
WholphinRenderersFactory(context, false)
.setEnableDecoderFallback(true)
.setExtensionRendererMode(rendererMode)
val mediaSourceFactory =
DefaultMediaSourceFactory(
OkHttpDataSource.Factory(authOkHttpClient),
extractorsFactory,
)
val trackSelector = createTrackSelector()
return ExoPlayer
.Builder(context)
.setMediaSourceFactory(mediaSourceFactory)
.setRenderersFactory(renderersFactory)
.setTrackSelector(trackSelector)
.build()
.also {
it.setAudioAttributes(
AudioAttributes
.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.build(),
false,
)
}
}
private fun createExtractorsFactory() =
DefaultExtractorsFactory()
.setConstantBitrateSeekingEnabled(true)
.setConstantBitrateSeekingAlwaysEnabled(true)
private fun createTrackSelector() =
DefaultTrackSelector(context).apply {
setParameters(
buildUponParameters()
.setAudioOffloadPreferences(
AudioOffloadPreferences
.Builder()
.setAudioOffloadMode(AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED)
.build(),
),
)
}
} }
val Player.isReleased: Boolean val Player.isReleased: Boolean
@ -157,11 +216,17 @@ val Player.isReleased: Boolean
} }
} }
data class PlayerCreation(
val player: Player,
val assHandler: AssHandler? = null,
)
// Code is adapted from https://github.com/androidx/media/blob/release/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/DefaultRenderersFactory.java#L436 // Code is adapted from https://github.com/androidx/media/blob/release/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/DefaultRenderersFactory.java#L436
class WholphinRenderersFactory( class WholphinRenderersFactory(
context: Context, context: Context,
private val av1Enabled: Boolean, private val av1Enabled: Boolean,
) : DefaultRenderersFactory(context) { ) : DefaultRenderersFactory(context) {
@OptIn(ExperimentalApi::class)
override fun buildVideoRenderers( override fun buildVideoRenderers(
context: Context, context: Context,
extensionRendererMode: Int, extensionRendererMode: Int,

View file

@ -38,6 +38,11 @@ import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/**
* Create [Playlist]s (not Jellyfin server playlist) for playback
*
* Used to create a queue of episodes or from Play All button
*/
@Singleton @Singleton
class PlaylistCreator class PlaylistCreator
@Inject @Inject
@ -74,6 +79,9 @@ class PlaylistCreator
return Playlist(episodes, startIndex) return Playlist(episodes, startIndex)
} }
/**
* Create from a server playlist ID
*/
suspend fun createFromPlaylistId( suspend fun createFromPlaylistId(
playlistId: UUID, playlistId: UUID,
startIndex: Int?, startIndex: Int?,
@ -114,7 +122,12 @@ class PlaylistCreator
req = req =
GetItemsRequest( GetItemsRequest(
parentId = item.id, parentId = item.id,
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB), enableImageTypes =
listOf(
ImageType.PRIMARY,
ImageType.THUMB,
ImageType.LOGO,
),
includeItemTypes = includeItemTypes, includeItemTypes = includeItemTypes,
recursive = true, recursive = true,
excludeItemIds = listOf(item.id), excludeItemIds = listOf(item.id),
@ -133,6 +146,11 @@ class PlaylistCreator
return Playlist(items, 0) return Playlist(items, 0)
} }
/**
* Create a [Playlist] contextually based on the given item.
*
* For example, an episode creates a queue of next up episodes in the series, or a movie creates one for its parts if needed
*/
suspend fun createFrom( suspend fun createFrom(
item: BaseItemDto, item: BaseItemDto,
startIndex: Int = 0, startIndex: Int = 0,
@ -270,6 +288,9 @@ class PlaylistCreator
} }
} }
/**
* Get the playlists on the server for a given media type
*/
suspend fun getServerPlaylists( suspend fun getServerPlaylists(
mediaType: MediaType?, mediaType: MediaType?,
scope: CoroutineScope, scope: CoroutineScope,

View file

@ -22,6 +22,9 @@ import javax.inject.Singleton
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
/**
* Handles determining whether the refresh rate and/or resolution of the display need to changed when playing media
*/
@Singleton @Singleton
class RefreshRateService class RefreshRateService
@Inject @Inject
@ -119,6 +122,9 @@ class RefreshRateService
MainActivity.instance.changeDisplayMode(0) MainActivity.instance.changeDisplayMode(0)
} }
/**
* Listens for the display to change so we known the refresh rate or resolution is updated
*/
private class Listener( private class Listener(
val displayId: Int, val displayId: Int,
) : DisplayManager.DisplayListener { ) : DisplayManager.DisplayListener {
@ -213,6 +219,9 @@ class RefreshRateService
} }
} }
/**
* Wrapper for a [Display.Mode]
*/
data class DisplayMode( data class DisplayMode(
val modeId: Int, val modeId: Int,
val physicalWidth: Int, val physicalWidth: Int,

View file

@ -38,6 +38,9 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
/**
* Handles the queue of items to show on the screensaver, both in-app or OS
*/
@Singleton @Singleton
class ScreensaverService class ScreensaverService
@Inject @Inject
@ -67,6 +70,9 @@ class ScreensaverService
}.launchIn(scope) }.launchIn(scope)
} }
/**
* Reset the timer before showing the in-app screensaver
*/
fun pulse() { fun pulse() {
waitJob?.cancel() waitJob?.cancel()
if (_state.value.enabled) { if (_state.value.enabled) {
@ -95,6 +101,9 @@ class ScreensaverService
} }
} }
/**
* Immediately start the in-app screensaver
*/
fun start() { fun start() {
_state.update { _state.update {
it.copy( it.copy(
@ -104,6 +113,9 @@ class ScreensaverService
} }
} }
/**
* Immediately stop the in-app screensaver
*/
fun stop(cancelJob: Boolean) { fun stop(cancelJob: Boolean) {
_state.update { _state.update {
it.copy( it.copy(
@ -114,6 +126,9 @@ class ScreensaverService
if (cancelJob) waitJob?.cancel() if (cancelJob) waitJob?.cancel()
} }
/**
* Signal to the OS for keeping the screen on such as during playback or when the in-app screensaver is active
*/
fun keepScreenOn(keep: Boolean) { fun keepScreenOn(keep: Boolean) {
scope.launchDefault { scope.launchDefault {
val screensaverEnabled = _state.value.enabled val screensaverEnabled = _state.value.enabled
@ -136,6 +151,9 @@ class ScreensaverService
keepScreenOn.update { keep } keepScreenOn.update { keep }
} }
/**
* Create a flow of items to show on the screensaver
*/
fun createItemFlow(scope: CoroutineScope): Flow<CurrentItem?> = fun createItemFlow(scope: CoroutineScope): Flow<CurrentItem?> =
flow { flow {
val prefs = val prefs =

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.services package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.setup.seerr.createSeerrApiUrl import com.github.damontecres.wholphin.ui.setup.seerr.createSeerrApiUrl
@ -19,7 +20,7 @@ class SeerrApi(
) )
private set private set
val active: Boolean get() = api.baseUrl.isNotNullOrBlank() val active: Boolean get() = api.baseUrl.isNotNullOrBlank() && BuildConfig.DISCOVER_ENABLED
fun update( fun update(
baseUrl: String, baseUrl: String,

View file

@ -4,6 +4,7 @@ import android.content.Context
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.asFlow import androidx.lifecycle.asFlow
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
import com.github.damontecres.wholphin.api.seerr.model.AuthJellyfinPostRequest import com.github.damontecres.wholphin.api.seerr.model.AuthJellyfinPostRequest
import com.github.damontecres.wholphin.api.seerr.model.AuthLocalPostRequest import com.github.damontecres.wholphin.api.seerr.model.AuthLocalPostRequest
@ -292,44 +293,46 @@ class UserSwitchListener
launchIO { launchIO {
homeSettingsService.loadCurrentSettings(user.id) homeSettingsService.loadCurrentSettings(user.id)
} }
// Check for seerr server if (BuildConfig.DISCOVER_ENABLED) {
launchIO { // Check for seerr server
seerrServerDao launchIO {
.getUsersByJellyfinUser(user.rowId) seerrServerDao
.lastOrNull() .getUsersByJellyfinUser(user.rowId)
?.let { seerrUser -> .lastOrNull()
val server = ?.let { seerrUser ->
seerrServerDao.getServer(seerrUser.serverId)?.server val server =
if (server != null) { seerrServerDao.getServer(seerrUser.serverId)?.server
Timber.i("Found a seerr user & server") if (server != null) {
try { Timber.i("Found a seerr user & server")
seerrApi.update(server.url, seerrUser.credential) try {
val userConfig = seerrApi.update(server.url, seerrUser.credential)
if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) { val userConfig =
login( if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
seerrApi.api, login(
seerrUser.authMethod, seerrApi.api,
seerrUser.username, seerrUser.authMethod,
seerrUser.password, seerrUser.username,
) seerrUser.password,
} else { )
seerrApi.api.usersApi.authMeGet() } else {
} seerrApi.api.usersApi.authMeGet()
seerrServerRepository.set( }
server, seerrServerRepository.set(
seerrUser, server,
userConfig, seerrUser,
) userConfig,
} catch (ex: Exception) { )
Timber.w( } catch (ex: Exception) {
ex, Timber.w(
"Error logging into %s", ex,
server.url, "Error logging into %s",
) server.url,
seerrServerRepository.error(server, seerrUser, ex) )
seerrServerRepository.error(server, seerrUser, ex)
}
} }
} }
} }
} }
} }
} }

View file

@ -191,6 +191,18 @@ class SeerrService
return "${base}${prefix}$path" return "${base}${prefix}$path"
} }
suspend fun createDiscoverItem(item: Any): DiscoverItem =
when (item) {
is MovieResult -> createDiscoverItem(item)
is MovieDetails -> createDiscoverItem(item)
is TvResult -> createDiscoverItem(item)
is TvDetails -> createDiscoverItem(item)
is SeerrSearchResult -> createDiscoverItem(item)
is CreditCast -> createDiscoverItem(item)
is CreditCrew -> createDiscoverItem(item)
else -> throw IllegalArgumentException("Unsupported type ${item::class.qualifiedName}")
}
suspend fun createDiscoverItem(movie: MovieResult): DiscoverItem = suspend fun createDiscoverItem(movie: MovieResult): DiscoverItem =
DiscoverItem( DiscoverItem(
id = movie.id, id = movie.id,

View file

@ -25,6 +25,9 @@ import org.jellyfin.sdk.model.api.MediaType
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
/**
* Listens for basic messages from the server such as messages
*/
@ActivityScoped @ActivityScoped
class ServerEventListener class ServerEventListener
@Inject @Inject

View file

@ -16,7 +16,9 @@ import javax.inject.Singleton
@Singleton @Singleton
class SetupNavigationManager class SetupNavigationManager
@Inject @Inject
constructor() { constructor(
private val navigationManager: NavigationManager,
) {
var backStack: MutableList<SetupDestination> = mutableStateListOf(SetupDestination.Loading) var backStack: MutableList<SetupDestination> = mutableStateListOf(SetupDestination.Loading)
/** /**
@ -25,6 +27,9 @@ class SetupNavigationManager
fun navigateTo(destination: SetupDestination) { fun navigateTo(destination: SetupDestination) {
backStack[0] = destination backStack[0] = destination
log() log()
if (destination !is SetupDestination.AppContent) {
navigationManager.reloadHome()
}
} }
private fun log() { private fun log() {

View file

@ -20,6 +20,9 @@ import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/**
* Manage the track choices for media
*/
@Singleton @Singleton
class StreamChoiceService class StreamChoiceService
@Inject @Inject
@ -94,6 +97,9 @@ class StreamChoiceService
result result
} }
/**
* Returns the audio stream that should play
*/
suspend fun chooseAudioStream( suspend fun chooseAudioStream(
source: MediaSourceInfo, source: MediaSourceInfo,
seriesId: UUID?, seriesId: UUID?,
@ -108,6 +114,9 @@ class StreamChoiceService
} }
} }
/**
* Returns the audio stream that should play
*/
fun chooseAudioStream( fun chooseAudioStream(
candidates: List<MediaStream>, candidates: List<MediaStream>,
itemPlayback: ItemPlayback?, itemPlayback: ItemPlayback?,
@ -135,6 +144,9 @@ class StreamChoiceService
} }
} }
/**
* Returns the subtitle stream that should play
*/
suspend fun chooseSubtitleStream( suspend fun chooseSubtitleStream(
source: MediaSourceInfo, source: MediaSourceInfo,
audioStream: MediaStream?, audioStream: MediaStream?,
@ -196,6 +208,9 @@ class StreamChoiceService
)?.index )?.index
} }
/**
* Returns the subtitle stream that should play
*/
fun chooseSubtitleStream( fun chooseSubtitleStream(
audioStreamLang: String?, audioStreamLang: String?,
candidates: List<MediaStream>, candidates: List<MediaStream>,

View file

@ -15,6 +15,9 @@ import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/**
* Gets trailers for media
*/
@Singleton @Singleton
class TrailerService class TrailerService
@Inject @Inject

View file

@ -45,6 +45,9 @@ import javax.inject.Singleton
import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
/**
* Checks if an app update is available
*/
@Singleton @Singleton
class UpdateChecker class UpdateChecker
@Inject @Inject
@ -62,9 +65,14 @@ class UpdateChecker
private val NOTE_REGEX = Regex("<!-- app-note:(.+) -->") private val NOTE_REGEX = Regex("<!-- app-note:(.+) -->")
val ACTIVE = true val ACTIVE = BuildConfig.UPDATING_ENABLED
} }
/**
* If the app hasn't recently checked, check for any updates and if there is one, show a toast message
*
* This is safe to call many times because it will only show the toast at most once every 12 hours
*/
suspend fun maybeShowUpdateToast( suspend fun maybeShowUpdateToast(
updateUrl: String, updateUrl: String,
showNegativeToast: Boolean = false, showNegativeToast: Boolean = false,
@ -112,55 +120,81 @@ class UpdateChecker
} }
} }
/**
* Get the currently installed version
*/
fun getInstalledVersion(): Version { fun getInstalledVersion(): Version {
val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0) val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0)
return Version.Companion.fromString(pkgInfo.versionName!!) return Version.fromString(pkgInfo.versionName!!)
} }
suspend fun getLatestRelease(updateUrl: String): Release? { 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(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url(url)
.get()
.build()
getRelease(request)
}
}
/**
* Get the latest released version
*/
suspend fun getLatestRelease(updateUrl: String): Release? =
withContext(Dispatchers.IO) {
val request = val request =
Request Request
.Builder() .Builder()
.url(updateUrl) .url(updateUrl)
.get() .get()
.build() .build()
okHttpClient.newCall(request).execute().use { getRelease(request)
if (it.isSuccessful && it.body != null) { }
val result = Json.parseToJsonElement(it.body!!.string())
val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull private fun getRelease(request: Request): Release? {
val version = Version.tryFromString(name) return okHttpClient.newCall(request).execute().use {
val publishedAt = if (it.isSuccessful && it.body != null) {
result.jsonObject["published_at"]?.jsonPrimitive?.contentOrNull val result = Json.parseToJsonElement(it.body!!.string())
val body = result.jsonObject["body"]?.jsonPrimitive?.contentOrNull val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull
val downloadUrl = val version = Version.tryFromString(name)
result.jsonObject["assets"] val publishedAt =
?.jsonArray result.jsonObject["published_at"]?.jsonPrimitive?.contentOrNull
?.let { assets -> getDownloadUrl(assets, BuildConfig.DEBUG) } val body = result.jsonObject["body"]?.jsonPrimitive?.contentOrNull
Timber.v("version=$version, downloadUrl=$downloadUrl") val downloadUrl =
if (version != null) { result.jsonObject["assets"]
val notes = ?.jsonArray
if (body.isNotNullOrBlank()) { ?.let { assets -> getDownloadUrl(assets, BuildConfig.DEBUG) }
NOTE_REGEX Timber.v("version=$version, downloadUrl=$downloadUrl")
.findAll(body) if (version != null) {
.map { m -> val notes =
m.groupValues[1] if (body.isNotNullOrBlank()) {
}.toList() NOTE_REGEX
} else { .findAll(body)
listOf() .map { m ->
} m.groupValues[1]
return@use Release(version, downloadUrl, publishedAt, body, notes) }.toList()
} else { } else {
Timber.w("Update version parsing failed. name=$name") emptyList()
} }
return@use Release(version, downloadUrl, publishedAt, body, notes)
} else { } else {
Timber.w("Update check failed: ${it.message}") Timber.w("Update version parsing failed. name=$name")
} }
return@use null } else {
Timber.w("Update check failed ${it.code}: ${it.message}")
} }
return@use null
} }
} }
/**
* Download and install an update
*/
suspend fun installRelease( suspend fun installRelease(
release: Release, release: Release,
callback: DownloadCallback, callback: DownloadCallback,
@ -263,6 +297,9 @@ class UpdateChecker
return targetFile return targetFile
} }
/**
* Check if the app has permission to write the download
*/
fun hasPermissions(): Boolean = fun hasPermissions(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
( (
@ -327,7 +364,19 @@ data class Release(
val publishedAt: String?, val publishedAt: String?,
val body: String?, val body: String?,
val notes: List<String>, val notes: List<String>,
) ) {
val content =
"# ${version}\n" +
(
(notes.joinToString("\n").takeIf { it.isNotNullOrBlank() } ?: "") +
(body ?: "")
).replace(
Regex("https://github.com/\\w*/\\w+/pull/(\\d+)"),
"#$1",
)
// Remove the last line for full changelog since its just a link
.replace(Regex("\\*\\*Full Changelog\\*\\*.*"), "")
}
interface DownloadCallback { interface DownloadCallback {
fun contentLength(contentLength: Long) fun contentLength(contentLength: Long)

View file

@ -9,6 +9,9 @@ import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/**
* Get the current user's [UserPreferences]
*/
@Singleton @Singleton
class UserPreferencesService class UserPreferencesService
@Inject @Inject

View file

@ -34,26 +34,48 @@ import org.jellyfin.sdk.model.DeviceInfo
import javax.inject.Qualifier import javax.inject.Qualifier
import javax.inject.Singleton import javax.inject.Singleton
/**
* An [OkHttpClient] that includes the user's access token when making requests
*/
@Qualifier @Qualifier
@Retention(AnnotationRetention.BINARY) @Retention(AnnotationRetention.BINARY)
annotation class AuthOkHttpClient annotation class AuthOkHttpClient
/**
* A basic [OkHttpClient] that does not include auth
*/
@Qualifier @Qualifier
@Retention(AnnotationRetention.BINARY) @Retention(AnnotationRetention.BINARY)
annotation class StandardOkHttpClient annotation class StandardOkHttpClient
/**
* A [CoroutineScope] with [Dispatchers.IO]
*/
@Qualifier @Qualifier
@Retention(AnnotationRetention.BINARY) @Retention(AnnotationRetention.BINARY)
annotation class IoCoroutineScope annotation class IoCoroutineScope
/**
* A [CoroutineScope] with [Dispatchers.Default]
*/
@Qualifier @Qualifier
@Retention(AnnotationRetention.BINARY) @Retention(AnnotationRetention.BINARY)
annotation class DefaultCoroutineScope annotation class DefaultCoroutineScope
/**
* [Dispatchers.IO]
*
* @see IoCoroutineScope
*/
@Qualifier @Qualifier
@Retention(AnnotationRetention.BINARY) @Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher annotation class IoDispatcher
/**
* [Dispatchers.Default]
*
* @see DefaultCoroutineScope
*/
@Qualifier @Qualifier
@Retention(AnnotationRetention.BINARY) @Retention(AnnotationRetention.BINARY)
annotation class DefaultDispatcher annotation class DefaultDispatcher

View file

@ -5,6 +5,9 @@ import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.dataStoreFile import androidx.datastore.dataStoreFile
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.room.Room import androidx.room.Room
import com.github.damontecres.wholphin.data.AppDatabase import com.github.damontecres.wholphin.data.AppDatabase
import com.github.damontecres.wholphin.data.ItemPlaybackDao import com.github.damontecres.wholphin.data.ItemPlaybackDao
@ -85,4 +88,13 @@ object DatabaseModule {
produceNewData = { AppPreferences.getDefaultInstance() }, produceNewData = { AppPreferences.getDefaultInstance() },
), ),
) )
@Provides
@Singleton
fun keyValueDataStore(
@ApplicationContext context: Context,
): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
produceFile = { context.preferencesDataStoreFile("key_value") },
)
} }

View file

@ -24,6 +24,9 @@ import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaDuration import kotlin.time.toJavaDuration
/**
* Schedules the [TvProviderWorker] to update the OS resume watching row
*/
@ActivityScoped @ActivityScoped
class TvProviderSchedulerService class TvProviderSchedulerService
@Inject @Inject
@ -44,6 +47,7 @@ class TvProviderSchedulerService
workManager.cancelUniqueWork(TvProviderWorker.WORK_NAME) workManager.cancelUniqueWork(TvProviderWorker.WORK_NAME)
if (supportsTvProvider) { if (supportsTvProvider) {
if (user != null) { if (user != null) {
// Schedule a new worker whenever the user changes
activity.lifecycleScope.launchIO(ExceptionHandler()) { activity.lifecycleScope.launchIO(ExceptionHandler()) {
Timber.i("Scheduling TvProviderWorker for ${user.user}") Timber.i("Scheduling TvProviderWorker for ${user.user}")
workManager workManager
@ -70,6 +74,9 @@ class TvProviderSchedulerService
} }
} }
/**
* Run the [TvProviderWorker] as a one-off instead of scheduled
*/
fun launchOneTimeRefresh() { fun launchOneTimeRefresh() {
if (supportsTvProvider) { if (supportsTvProvider) {
activity.lifecycleScope.launchIO(ExceptionHandler()) { activity.lifecycleScope.launchIO(ExceptionHandler()) {

View file

@ -43,6 +43,11 @@ import java.util.Date
import java.util.UUID import java.util.UUID
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
/**
* Updates the Android OS resume watching row for a given Jellyfin user
*
* @see TvProviderSchedulerService
*/
@HiltWorker @HiltWorker
class TvProviderWorker class TvProviderWorker
@AssistedInject @AssistedInject

View file

@ -18,7 +18,7 @@ class CoilTrickplayTransformation(
val index: Int, val index: Int,
) : Transformation() { ) : Transformation() {
private val x: Int = imageIndex % numColumns private val x: Int = imageIndex % numColumns
private val y: Int = imageIndex / numRows private val y: Int = imageIndex / numColumns
override val cacheKey: String override val cacheKey: String
get() = "CoilTrickplayTransformation_$index,$x,$y" get() = "CoilTrickplayTransformation_$index,$x,$y"

View file

@ -38,6 +38,9 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.acra.ACRA import org.acra.ACRA
@ -434,3 +437,18 @@ fun Response<BaseItemDtoQueryResult>.toBaseItems(
fun Int?.gt(that: Int) = (this ?: 0) > that fun Int?.gt(that: Int) = (this ?: 0) > that
fun Int?.lt(that: Int) = (this ?: 0) < that fun Int?.lt(that: Int) = (this ?: 0) < that
/**
* Simplifies endlessly collecting a flow
*/
fun <T> Flow<T>.collectLatestIn(
scope: CoroutineScope,
action: suspend (value: T) -> Unit,
) {
scope.launchDefault { this@collectLatestIn.collectLatest(action) }
}
/**
* 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) }

View file

@ -7,6 +7,7 @@ import androidx.compose.ui.text.buildAnnotatedString
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.WholphinApplication
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaSegmentType import org.jellyfin.sdk.model.api.MediaSegmentType
import timber.log.Timber import timber.log.Timber
import java.time.LocalDate import java.time.LocalDate
@ -16,8 +17,25 @@ import java.time.format.DateTimeParseException
import java.time.format.FormatStyle import java.time.format.FormatStyle
import java.util.Locale import java.util.Locale
val TimeFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) private var timeFormatter: DateTimeFormatter =
val DateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("MMM d, yyyy") DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(Locale.getDefault())
fun getTimeFormatter(): DateTimeFormatter {
if (timeFormatter.locale != Locale.getDefault()) {
timeFormatter = timeFormatter.withLocale(Locale.getDefault())
}
return timeFormatter
}
private var dateFormatter: DateTimeFormatter =
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(Locale.getDefault())
fun getDateFormatter(): DateTimeFormatter {
if (dateFormatter.locale != Locale.getDefault()) {
dateFormatter = dateFormatter.withLocale(Locale.getDefault())
}
return dateFormatter
}
// TODO server returns in UTC, but sdk converts to local time // TODO server returns in UTC, but sdk converts to local time
// eg 2020-02-14T00:00:00.0000000Z => 2020-02-13T17:00:00 PT => Feb 13, 2020 // eg 2020-02-14T00:00:00.0000000Z => 2020-02-13T17:00:00 PT => Feb 13, 2020
@ -25,9 +43,11 @@ val DateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("MMM d, yyyy"
/** /**
* Format a [LocalDateTime] as `Aug 24, 2000` * Format a [LocalDateTime] as `Aug 24, 2000`
*/ */
fun formatDateTime(dateTime: LocalDateTime): String = DateFormatter.format(dateTime) fun formatDateTime(dateTime: LocalDateTime): String = getDateFormatter().format(dateTime)
fun formatDate(dateTime: LocalDate): String = DateFormatter.format(dateTime) fun formatDate(dateTime: LocalDate): String = getDateFormatter().format(dateTime)
fun formatDate(dateTime: LocalDateTime): String = getDateFormatter().format(dateTime)
fun toLocalDate(date: String?): LocalDate? = fun toLocalDate(date: String?): LocalDate? =
date?.let { date?.let {
@ -109,30 +129,25 @@ fun abbreviateNumber(number: Int): String {
return String.format(Locale.getDefault(), "%.1f%s", count, abbrevSuffixes[unit]) return String.format(Locale.getDefault(), "%.1f%s", count, abbrevSuffixes[unit])
} }
val byteSuffixes = listOf("B", "KB", "MB", "GB", "TB") val byteSuffixes = listOf("B", "KiB", "MiB", "GiB", "TiB")
val byteRateSuffixes = listOf("bps", "kbps", "mbps", "gbps", "tbps") val byteRateSuffixes = listOf("bps", "kbps", "mbps", "gbps", "tbps")
/**
* Format bytes
*/
fun formatBytes(
bytes: Int,
suffixes: List<String> = byteSuffixes,
) = formatBytes(bytes.toLong(), suffixes)
fun formatBytes( fun formatBytes(
bytes: Long, bytes: Long,
suffixes: List<String> = byteSuffixes, suffixes: List<String> = byteSuffixes,
divisor: Int = 1024,
): String { ): String {
var unit = 0 var unit = 0
var count = bytes.toDouble() var count = bytes.toDouble()
while (count >= 1024 && unit + 1 < suffixes.size) { while (count >= divisor && unit + 1 < suffixes.size) {
count /= 1024 count /= divisor
unit++ unit++
} }
return String.format(Locale.getDefault(), "%.2f%s", count, suffixes[unit]) return String.format(Locale.getDefault(), "%.2f %s", count, suffixes[unit])
} }
fun formatBitrate(bitrate: Int) = formatBytes(bitrate.toLong(), byteRateSuffixes, 1000)
@get:StringRes @get:StringRes
val MediaSegmentType.stringRes: Int val MediaSegmentType.stringRes: Int
get() = get() =
@ -184,3 +199,45 @@ fun listToDotString(
} }
} }
} }
@StringRes
fun formatTypeName(type: BaseItemKind): Int =
when (type) {
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
BaseItemKind.BOX_SET -> R.string.collections
BaseItemKind.AUDIO -> TODO()
BaseItemKind.CHANNEL -> R.string.channels
BaseItemKind.GENRE -> R.string.genres
BaseItemKind.LIVE_TV_CHANNEL -> R.string.channels
BaseItemKind.MUSIC_ALBUM -> TODO()
BaseItemKind.MUSIC_ARTIST -> TODO()
BaseItemKind.MUSIC_GENRE -> TODO()
BaseItemKind.MUSIC_VIDEO -> TODO()
BaseItemKind.PHOTO -> R.string.photos
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
BaseItemKind.TV_CHANNEL -> R.string.channels
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

@ -19,6 +19,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -98,10 +99,15 @@ fun BannerCard(
} }
} }
var imageError by remember(imageUrl) { mutableStateOf(false) } var imageError by remember(imageUrl) { mutableStateOf(false) }
// Stabilize callbacks to prevent AsyncImage from recomposing
val currentOnClick by rememberUpdatedState(onClick)
val currentOnLongClick by rememberUpdatedState(onLongClick)
Card( Card(
modifier = modifier.size(cardHeight * aspectRatio, cardHeight), modifier = modifier.size(cardHeight * aspectRatio, cardHeight),
onClick = onClick, onClick = { currentOnClick() },
onLongClick = onLongClick, onLongClick = { currentOnLongClick() },
interactionSource = interactionSource, interactionSource = interactionSource,
colors = colors =
CardDefaults.colors( CardDefaults.colors(
@ -119,7 +125,7 @@ fun BannerCard(
model = imageUrl, model = imageUrl,
contentDescription = null, contentDescription = null,
contentScale = imageContentScale, contentScale = imageContentScale,
onError = { imageError = true }, onError = remember { { imageError = true } },
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} else { } else {
@ -214,7 +220,7 @@ fun BannerCardWithTitle(
) { ) {
val focused by interactionSource.collectIsFocusedAsState() val focused by interactionSource.collectIsFocusedAsState()
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp) val spaceBelow by animateDpAsState(if (focused) 0.dp else 8.dp)
val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource) val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN) val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
val width = cardHeight * aspectRationToUse val width = cardHeight * aspectRationToUse

View file

@ -14,9 +14,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -34,8 +35,10 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.tv.material3.Card import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults import androidx.tv.material3.CardDefaults
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.SeerrAvailability import com.github.damontecres.wholphin.data.model.SeerrAvailability
@ -50,14 +53,14 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@Composable @Composable
@NonRestartableComposable
fun DiscoverItemCard( fun DiscoverItemCard(
item: DiscoverItem?, item: DiscoverItem?,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
showOverlay: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
showOverlay: Boolean = true,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
width: Dp = Cards.height2x3 * AspectRatios.TALL,
) { ) {
val focused by interactionSource.collectIsFocusedAsState() val focused by interactionSource.collectIsFocusedAsState()
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
@ -77,16 +80,15 @@ fun DiscoverItemCard(
} else { } else {
focusedAfterDelay = false focusedAfterDelay = false
} }
val width = Cards.height2x3 * AspectRatios.TALL
val height = Dp.Unspecified * (1f / AspectRatios.TALL)
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(spaceBetween), verticalArrangement = Arrangement.spacedBy(spaceBetween),
modifier = modifier.size(width, height), modifier = modifier.size(width, Dp.Unspecified),
) { ) {
Card( Card(
modifier = modifier =
Modifier Modifier
.size(Dp.Unspecified, Cards.height2x3) .size(width, Dp.Unspecified)
.aspectRatio(AspectRatios.TALL), .aspectRatio(AspectRatios.TALL),
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
@ -286,6 +288,88 @@ fun PartiallyAvailableIndicator(modifier: Modifier = Modifier) {
} }
} }
@Composable
fun DiscoverViewMoreCard(
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val focused by interactionSource.collectIsFocusedAsState()
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
var focusedAfterDelay by remember { mutableStateOf(false) }
val hideOverlayDelay = 500L
if (focused) {
LaunchedEffect(Unit) {
delay(hideOverlayDelay)
if (focused) {
focusedAfterDelay = true
} else {
focusedAfterDelay = false
}
}
} else {
focusedAfterDelay = false
}
val width = Cards.height2x3 * AspectRatios.TALL
val height = Dp.Unspecified * (1f / AspectRatios.TALL)
Column(
verticalArrangement = Arrangement.spacedBy(spaceBetween),
modifier = modifier.size(width, height),
) {
Card(
modifier =
Modifier
.size(Dp.Unspecified, Cards.height2x3)
.aspectRatio(AspectRatios.TALL),
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
colors =
CardDefaults.colors(
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
),
) {
Box(
modifier =
Modifier
.fillMaxSize(),
) {
Icon(
imageVector = Icons.Default.ArrowForward,
tint = MaterialTheme.colorScheme.onSurface,
contentDescription = "View more",
modifier = Modifier.fillMaxSize(),
)
}
}
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
.padding(bottom = spaceBelow)
.fillMaxWidth(),
) {
Text(
text = stringResource(R.string.view_more),
maxLines = 1,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.enableMarquee(focusedAfterDelay),
)
}
}
}
@PreviewTvSpec @PreviewTvSpec
@Composable @Composable
private fun Preview() { private fun Preview() {
@ -294,6 +378,11 @@ private fun Preview() {
PendingIndicator() PendingIndicator()
AvailableIndicator() AvailableIndicator()
PartiallyAvailableIndicator() PartiallyAvailableIndicator()
DiscoverViewMoreCard(
onClick = {},
onLongClick = {},
modifier = Modifier,
)
} }
} }
} }

View file

@ -45,6 +45,7 @@ fun EpisodeCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
imageHeight: Dp = Dp.Unspecified, imageHeight: Dp = Dp.Unspecified,
imageWidth: Dp = Dp.Unspecified, imageWidth: Dp = Dp.Unspecified,
showImageOverlay: Boolean = false,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) { ) {
val dto = item?.data val dto = item?.data
@ -94,7 +95,7 @@ fun EpisodeCard(
ItemCardImage( ItemCardImage(
item = item, item = item,
name = item?.name, name = item?.name,
showOverlay = false, showOverlay = showImageOverlay,
favorite = dto?.userData?.isFavorite ?: false, favorite = dto?.userData?.isFavorite ?: false,
watched = dto?.userData?.played ?: false, watched = dto?.userData?.played ?: false,
unwatchedCount = dto?.userData?.unplayedItemCount ?: -1, unwatchedCount = dto?.userData?.unplayedItemCount ?: -1,

View file

@ -10,8 +10,10 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
@ -45,6 +47,10 @@ fun <T> ItemRow(
val firstFocus = remember { FocusRequester() } val firstFocus = remember { FocusRequester() }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
var position by rememberInt() var position by rememberInt()
val currentOnClickItem by rememberUpdatedState(onClickItem)
val currentOnLongClickItem by rememberUpdatedState(onLongClickItem)
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier =
@ -54,12 +60,8 @@ fun <T> ItemRow(
} }
}, },
) { ) {
Text( ItemRowTitle(title)
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(start = 8.dp),
)
LazyRow( LazyRow(
state = state, state = state,
horizontalArrangement = Arrangement.spacedBy(horizontalPadding), horizontalArrangement = Arrangement.spacedBy(horizontalPadding),
@ -73,25 +75,50 @@ fun <T> ItemRow(
) { ) {
itemsIndexed(items) { index, item -> itemsIndexed(items) { index, item ->
val cardModifier = val cardModifier =
if (index == position) { remember(index, position) {
Modifier.focusRequester(firstFocus) if (index == position) {
} else { Modifier.focusRequester(firstFocus)
Modifier } else {
Modifier
}
} }
val onClick =
remember(index, item) {
{
position = index
if (item != null) currentOnClickItem(index, item)
}
}
val onLongClick =
remember(index, item) {
{
position = index
if (item != null) currentOnLongClickItem(index, item)
}
}
cardContent.invoke( cardContent.invoke(
index, index,
item, item,
cardModifier, cardModifier,
{ onClick,
position = index onLongClick,
if (item != null) onClickItem.invoke(index, item)
},
{
position = index
if (item != null) onLongClickItem.invoke(index, item)
},
) )
} }
} }
} }
} }
@Composable
@NonRestartableComposable
fun ItemRowTitle(
title: String,
modifier: Modifier = Modifier,
) = Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
modifier = modifier.padding(start = 8.dp),
)

View file

@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
@ -25,9 +26,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.layout
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.tv.material3.Border import androidx.tv.material3.Border
@ -80,7 +83,7 @@ fun PersonCard(
) { ) {
val hideOverlayDelay = 1_000L val hideOverlayDelay = 1_000L
val focused = interactionSource.collectIsFocusedAsState().value val focused by interactionSource.collectIsFocusedAsState()
var focusedAfterDelay by remember { mutableStateOf(false) } var focusedAfterDelay by remember { mutableStateOf(false) }
if (focused) { if (focused) {
@ -95,10 +98,13 @@ fun PersonCard(
} else { } else {
focusedAfterDelay = false focusedAfterDelay = false
} }
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp) // 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( Column(
verticalArrangement = Arrangement.spacedBy(spaceBetween), verticalArrangement = Arrangement.spacedBy(4.dp), // Fixed base spacing
modifier = modifier, modifier = modifier,
) { ) {
Card( Card(
@ -168,8 +174,16 @@ fun PersonCard(
verticalArrangement = Arrangement.spacedBy(0.dp), verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = modifier =
Modifier Modifier
.padding(bottom = spaceBelow) // Optimization: move animation reads to layout/draw phase
.fillMaxWidth(), .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(
text = name ?: "", text = name ?: "",

View file

@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -18,10 +19,12 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.Card import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults import androidx.tv.material3.CardDefaults
@ -52,7 +55,7 @@ fun SeasonCard(
val imageUrlService = LocalImageUrlService.current val imageUrlService = LocalImageUrlService.current
val density = LocalDensity.current val density = LocalDensity.current
val imageUrl = val imageUrl =
remember(item, imageHeight, imageWidth) { remember(item, imageHeight, imageWidth, density) {
if (item != null) { if (item != null) {
val fillHeight = val fillHeight =
if (imageHeight != Dp.Unspecified) { if (imageHeight != Dp.Unspecified) {
@ -125,14 +128,17 @@ fun SeasonCard(
aspectRatio: Float = AspectRatios.TALL, aspectRatio: Float = AspectRatios.TALL,
) { ) {
val focused by interactionSource.collectIsFocusedAsState() val focused by interactionSource.collectIsFocusedAsState()
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) // Do not use `by` here, this way we are Defer reads and recompositions to only when modifier calculates
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp) 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 focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN) val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
val width = imageHeight * aspectRationToUse val width = imageHeight * aspectRationToUse
val height = imageWidth * (1f / aspectRationToUse) val height = imageWidth * (1f / aspectRationToUse)
Column( Column(
verticalArrangement = Arrangement.spacedBy(spaceBetween), verticalArrangement = Arrangement.spacedBy(4.dp), // Fixed base spacing
modifier = modifier.size(width, height), modifier = modifier.size(width, height),
) { ) {
Card( Card(
@ -173,8 +179,16 @@ fun SeasonCard(
verticalArrangement = Arrangement.spacedBy(0.dp), verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = modifier =
Modifier Modifier
.padding(bottom = spaceBelow) // Optimization: move animation reads to layout/draw phase
.fillMaxWidth(), .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 + paddingPx) {
placeable.placeRelative(0, 0)
}
}.fillMaxWidth(),
) { ) {
Text( Text(
text = title ?: "", text = title ?: "",

View file

@ -0,0 +1,150 @@
package com.github.damontecres.wholphin.ui.cards
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
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.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
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
@Composable
fun StudioCard(
studio: Studio?,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) = StudioCard(
studioId = studio?.id,
name = studio?.name,
imageUrl = studio?.imageUrl,
onClick = onClick,
onLongClick = onLongClick,
modifier = modifier,
interactionSource = interactionSource,
)
@Composable
fun StudioCard(
studioId: UUID?,
name: String?,
imageUrl: String?,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val background = rememberIdColor(studioId).copy(alpha = .4f)
var error by remember { mutableStateOf(false) }
Card(
modifier = modifier,
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
colors =
CardDefaults.colors(
containerColor = Color.Transparent,
),
) {
Box(
contentAlignment = Alignment.Center,
modifier =
Modifier
.aspectRatio(AspectRatios.WIDE)
.fillMaxSize()
.clip(RoundedCornerShape(8.dp)),
) {
if (imageUrl != null && !error) {
AsyncImage(
model =
ImageRequest
.Builder(LocalContext.current)
.data(imageUrl)
.crossfade(true)
.build(),
contentScale = ContentScale.FillBounds,
contentDescription = null,
onError = {
error = true
},
modifier =
Modifier
.alpha(.75f)
.aspectRatio(AspectRatios.WIDE)
.fillMaxSize(),
)
} else {
Box(
modifier =
Modifier
.aspectRatio(AspectRatios.WIDE)
.fillMaxSize()
.background(background),
) {
Text(
text = name ?: "",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier =
Modifier
.padding(16.dp)
.align(Alignment.Center),
)
}
}
}
}
}
@PreviewTvSpec
@Composable
private fun GenreCardPreview() {
WholphinTheme {
val studio =
Studio(
UUID.randomUUID(),
"Adventure",
null,
)
StudioCard(
studio = studio,
onClick = {},
onLongClick = {},
modifier = Modifier.width(180.dp),
)
}
}

View file

@ -35,6 +35,11 @@ import androidx.tv.material3.ProvideTextStyle
import androidx.tv.material3.Surface import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
/**
* This is a re-implementation of [androidx.tv.material3.Button] with altered sizing, padding, colors, etc
*
* This allows for creating smaller and fully circular Buttons.
*/
@Composable @Composable
fun Button( fun Button(
onClick: () -> Unit, onClick: () -> Unit,

View file

@ -21,6 +21,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -63,6 +64,7 @@ import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.MusicService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.UserPreferencesService
@ -79,6 +81,7 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.detail.music.addToQueue
import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.equalsNotNull
import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
@ -138,6 +141,7 @@ class CollectionFolderViewModel
private val themeSongPlayer: ThemeSongPlayer, private val themeSongPlayer: ThemeSongPlayer,
private val userPreferencesService: UserPreferencesService, private val userPreferencesService: UserPreferencesService,
private val mediaManagementService: MediaManagementService, private val mediaManagementService: MediaManagementService,
private val musicService: MusicService,
val mediaReportService: MediaReportService, val mediaReportService: MediaReportService,
@Assisted itemId: String, @Assisted itemId: String,
@Assisted initialSortAndDirection: SortAndDirection?, @Assisted initialSortAndDirection: SortAndDirection?,
@ -398,6 +402,7 @@ class CollectionFolderViewModel
ImageType.PRIMARY, ImageType.PRIMARY,
ImageType.THUMB, ImageType.THUMB,
ImageType.BACKDROP, ImageType.BACKDROP,
ImageType.LOGO,
), ),
includeItemTypes = includeItemTypes, includeItemTypes = includeItemTypes,
recursive = recursive, recursive = recursive,
@ -530,6 +535,11 @@ class CollectionFolderViewModel
item: BaseItem, item: BaseItem,
appPreferences: AppPreferences, appPreferences: AppPreferences,
): Boolean = mediaManagementService.canDelete(item, appPreferences) ): Boolean = mediaManagementService.canDelete(item, appPreferences)
fun addToQueue(
item: BaseItem,
index: Int,
) = addToQueue(api, musicService, item, index)
} }
/** /**
@ -774,6 +784,9 @@ fun CollectionFolderGrid(
onClickGoTo = { onClickGoTo = {
onClickItem.invoke(position, it) onClickItem.invoke(position, it)
}, },
onClickAddToQueue = {
viewModel.addToQueue(it, 0)
},
), ),
), ),
onDismissRequest = { moreDialog.makeAbsent() }, onDismissRequest = { moreDialog.makeAbsent() },
@ -881,15 +894,7 @@ fun CollectionFolderGridContent(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
if (showTitle) { if (showTitle) {
Text( GridTitle(title)
text = title,
style = MaterialTheme.typography.displayMedium,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
} }
val endPadding = val endPadding =
16.dp + if (sortAndDirection.sort == ItemSortBy.SORT_NAME) 24.dp else 0.dp 16.dp + if (sortAndDirection.sort == ItemSortBy.SORT_NAME) 24.dp else 0.dp
@ -960,11 +965,12 @@ fun CollectionFolderGridContent(
AnimatedVisibility(viewOptions.showDetails) { AnimatedVisibility(viewOptions.showDetails) {
HomePageHeader( HomePageHeader(
item = focusedItem, item = focusedItem,
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(200.dp) .height(200.dp)
.padding(top = 48.dp, bottom = 32.dp, start = 8.dp), .padding(HeaderUtils.padding),
) )
} }
when (val state = loadingState) { when (val state = loadingState) {
@ -1144,3 +1150,18 @@ val CollectionType.baseItemKinds: List<BaseItemKind>
listOf() listOf()
} }
} }
@Composable
@NonRestartableComposable
fun GridTitle(
title: String,
modifier: Modifier = Modifier,
) = Text(
text = title,
style = MaterialTheme.typography.displayMedium,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier.fillMaxWidth(),
)

View file

@ -76,6 +76,8 @@ import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID
/** /**
* Parameters for rendering a [DialogPopup] * Parameters for rendering a [DialogPopup]
@ -90,6 +92,9 @@ sealed interface DialogItemEntry
data object DialogItemDivider : DialogItemEntry data object DialogItemDivider : DialogItemEntry
/**
* An item within a [DialogPopup]
*/
data class DialogItem( data class DialogItem(
val headlineContent: @Composable () -> Unit, val headlineContent: @Composable () -> Unit,
val onClick: () -> Unit, val onClick: () -> Unit,
@ -594,6 +599,7 @@ fun ConfirmDeleteDialog(
fun chooseVersionParams( fun chooseVersionParams(
context: Context, context: Context,
sources: List<MediaSourceInfo>, sources: List<MediaSourceInfo>,
chosenSourceId: UUID?,
onClick: (Int) -> Unit, onClick: (Int) -> Unit,
): DialogParams = ): DialogParams =
DialogParams( DialogParams(
@ -601,6 +607,7 @@ fun chooseVersionParams(
title = context.getString(R.string.choose_stream, context.getString(R.string.version)), title = context.getString(R.string.choose_stream, context.getString(R.string.version)),
items = items =
sources.filter { it.id.isNotNullOrBlank() }.mapIndexed { index, source -> sources.filter { it.id.isNotNullOrBlank() }.mapIndexed { index, source ->
val uuid = source.id?.toUUIDOrNull()
val videoStream = val videoStream =
source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO } source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }
val title = source.name ?: source.path ?: source.id ?: "" val title = source.name ?: source.path ?: source.id ?: ""
@ -608,6 +615,9 @@ fun chooseVersionParams(
headlineContent = { headlineContent = {
Text(text = title) Text(text = title)
}, },
leadingContent = {
SelectedLeadingContent(uuid != null && uuid == chosenSourceId)
},
supportingContent = { supportingContent = {
videoStream?.displayTitle?.let { Text(text = it) } videoStream?.displayTitle?.let { Text(text = it) }
}, },

View file

@ -34,7 +34,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
@ -49,8 +48,10 @@ import androidx.tv.material3.MaterialTheme
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.google.protobuf.value
/**
* An input field for text customized for TV entry
*/
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun EditTextBox( fun EditTextBox(

View file

@ -14,6 +14,7 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
@ -98,3 +99,13 @@ fun ErrorMessage(
exception = error.exception, exception = error.exception,
modifier = modifier, modifier = modifier,
) )
@Composable
fun ErrorMessage(
error: DataLoadingState.Error,
modifier: Modifier = Modifier,
) = ErrorMessage(
message = error.message,
exception = error.exception,
modifier = modifier,
)

View file

@ -46,6 +46,7 @@ import com.github.damontecres.wholphin.data.filter.GenreFilter
import com.github.damontecres.wholphin.data.filter.ItemFilterBy import com.github.damontecres.wholphin.data.filter.ItemFilterBy
import com.github.damontecres.wholphin.data.filter.OfficialRatingFilter import com.github.damontecres.wholphin.data.filter.OfficialRatingFilter
import com.github.damontecres.wholphin.data.filter.PlayedFilter 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.VideoTypeFilter
import com.github.damontecres.wholphin.data.filter.YearFilter import com.github.damontecres.wholphin.data.filter.YearFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter
@ -55,6 +56,14 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import java.util.UUID import java.util.UUID
/**
* Button for filtering data.
*
* Clicking on it will show a drop-down menu of filterable options. Many of these show a second drop-down menu when clicked.
*
* @see GetItemsFilter
* @see ItemFilterBy
*/
@Composable @Composable
fun FilterByButton( fun FilterByButton(
filterOptions: List<ItemFilterBy<*>>, filterOptions: List<ItemFilterBy<*>>,
@ -198,7 +207,9 @@ fun FilterByButton(
val isSelected = val isSelected =
remember(currentValue) { remember(currentValue) {
when (filterOption) { when (filterOption) {
GenreFilter -> { GenreFilter,
StudioFilter,
-> {
(currentValue as? List<UUID>) (currentValue as? List<UUID>)
.orEmpty() .orEmpty()
.contains(value.value) .contains(value.value)
@ -263,7 +274,9 @@ fun FilterByButton(
onClick = { onClick = {
val newFilter = val newFilter =
when (filterOption) { when (filterOption) {
GenreFilter -> { GenreFilter,
StudioFilter,
-> {
val list = (currentValue as? List<UUID>).orEmpty() val list = (currentValue as? List<UUID>).orEmpty()
val newValue = val newValue =
list list

View file

@ -139,6 +139,7 @@ class GenreViewModel
nameLessThan = letter.toString(), nameLessThan = letter.toString(),
limit = 0, limit = 0,
enableTotalRecordCount = true, enableTotalRecordCount = true,
includeItemTypes = includeItemTypes,
) )
val result by GetGenresRequestHandler.execute(api, request) val result by GetGenresRequestHandler.execute(api, request)
return@withContext result.totalRecordCount return@withContext result.totalRecordCount
@ -156,6 +157,9 @@ private val genreCache by lazy {
} }
} }
/**
* Create a mapping from genre IDs to image URLs using random items within each genre
*/
suspend fun getGenreImageMap( suspend fun getGenreImageMap(
api: ApiClient, api: ApiClient,
userId: UUID?, userId: UUID?,
@ -230,6 +234,9 @@ data class Genre(
override val sortName: String get() = name override val sortName: String get() = name
} }
/**
* Show an optimized grid of genres for a library
*/
@Composable @Composable
fun GenreCardGrid( fun GenreCardGrid(
itemId: UUID, itemId: UUID,

View file

@ -11,6 +11,11 @@ import androidx.tv.material3.Text
private const val MAX_TO_SHOW = 4 private const val MAX_TO_SHOW = 4
/**
* Display a comma separated list of genres
*
* Only the first few will be shown
*/
@Composable @Composable
fun GenreText( fun GenreText(
genres: List<String>, genres: List<String>,

View file

@ -0,0 +1,24 @@
package com.github.damontecres.wholphin.ui.components
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
object HeaderUtils {
val topPadding = 48.dp
val bottomPadding = 32.dp
val startPadding = 8.dp
val padding = PaddingValues(top = topPadding, bottom = bottomPadding, start = startPadding)
val height = 180.dp
val logoHeight = 60.dp
val modifier =
Modifier
.padding(padding)
.height(height)
}

View file

@ -82,6 +82,9 @@ class ItemGridViewModel
} }
} }
/**
* Display a grid of a list of arbitrary item IDs such as for [com.github.damontecres.wholphin.data.ExtrasItem]
*/
@Composable @Composable
fun ItemGrid( fun ItemGrid(
destination: Destination.ItemGrid, destination: Destination.ItemGrid,

View file

@ -6,6 +6,9 @@ import androidx.compose.ui.Modifier
import com.mikepenz.aboutlibraries.ui.compose.android.produceLibraries import com.mikepenz.aboutlibraries.ui.compose.android.produceLibraries
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
/**
* Displays dependencies' license information to comply with attribution
*/
@Composable @Composable
fun LicenseInfo(modifier: Modifier = Modifier) { fun LicenseInfo(modifier: Modifier = Modifier) {
val libraries by produceLibraries() val libraries by produceLibraries()

View file

@ -23,6 +23,9 @@ import androidx.tv.material3.Text
import com.github.damontecres.wholphin.ui.playOnClickSound import com.github.damontecres.wholphin.ui.playOnClickSound
import com.github.damontecres.wholphin.ui.playSoundOnFocus import com.github.damontecres.wholphin.ui.playSoundOnFocus
/**
* Show the overview text for an item. Uses a fixed size and allows for clicking.
*/
@Composable @Composable
fun OverviewText( fun OverviewText(
overview: String, overview: String,

View file

@ -21,8 +21,8 @@ import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.TimeFormatter
import com.github.damontecres.wholphin.ui.dot import com.github.damontecres.wholphin.ui.dot
import com.github.damontecres.wholphin.ui.getTimeFormatter
import com.github.damontecres.wholphin.ui.util.LocalClock import com.github.damontecres.wholphin.ui.util.LocalClock
import kotlin.time.Duration import kotlin.time.Duration
@ -107,7 +107,7 @@ fun TimeRemaining(
val now by LocalClock.current.now val now by LocalClock.current.now
val remainingStr = val remainingStr =
remember(remaining, now) { remember(remaining, now) {
val endTimeStr = TimeFormatter.format(now.plusSeconds(remaining.inWholeSeconds)) val endTimeStr = getTimeFormatter().format(now.plusSeconds(remaining.inWholeSeconds))
buildAnnotatedString { buildAnnotatedString {
dot() dot()
append(context.getString(R.string.ends_at, endTimeStr)) append(context.getString(R.string.ends_at, endTimeStr))

View file

@ -19,12 +19,14 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.MusicService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.services.deleteItem
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
@ -34,6 +36,7 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.detail.music.addToQueue
import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.HomePageContent import com.github.damontecres.wholphin.ui.main.HomePageContent
@ -42,18 +45,25 @@ import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.MediaType
import java.util.UUID import java.util.UUID
/**
* Abstract [ViewModel] for the "Recommended" tab for a library
*/
abstract class RecommendedViewModel( abstract class RecommendedViewModel(
val context: Context, @param:ApplicationContext val context: Context,
val api: ApiClient,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
val favoriteWatchManager: FavoriteWatchManager, val favoriteWatchManager: FavoriteWatchManager,
val mediaReportService: MediaReportService, val mediaReportService: MediaReportService,
private val musicService: MusicService,
private val backdropService: BackdropService, private val backdropService: BackdropService,
private val mediaManagementService: MediaManagementService, private val mediaManagementService: MediaManagementService,
) : ViewModel() { ) : ViewModel() {
@ -110,13 +120,14 @@ abstract class RecommendedViewModel(
fun update( fun update(
@StringRes title: Int, @StringRes title: Int,
viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
block: suspend () -> List<BaseItem>, block: suspend () -> List<BaseItem>,
): Deferred<HomeRowLoadingState> = ): Deferred<HomeRowLoadingState> =
viewModelScope.async(Dispatchers.IO) { viewModelScope.async(Dispatchers.IO) {
val titleStr = context.getString(title) val titleStr = context.getString(title)
val row = val row =
try { try {
HomeRowLoadingState.Success(titleStr, block.invoke()) HomeRowLoadingState.Success(titleStr, block.invoke(), viewOptions)
} catch (ex: Exception) { } catch (ex: Exception) {
HomeRowLoadingState.Error(titleStr, null, ex) HomeRowLoadingState.Error(titleStr, null, ex)
} }
@ -141,6 +152,11 @@ abstract class RecommendedViewModel(
item: BaseItem, item: BaseItem,
appPreferences: AppPreferences, appPreferences: AppPreferences,
): Boolean = mediaManagementService.canDelete(item, appPreferences) ): Boolean = mediaManagementService.canDelete(item, appPreferences)
fun addToQueue(
item: BaseItem,
index: Int,
) = addToQueue(api, musicService, item, index)
} }
@Composable @Composable
@ -205,6 +221,7 @@ fun RecommendedContent(
}, },
showClock = preferences.appPreferences.interfacePreferences.showClock, showClock = preferences.appPreferences.interfacePreferences.showClock,
onUpdateBackdrop = viewModel::updateBackdrop, onUpdateBackdrop = viewModel::updateBackdrop,
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
modifier = modifier, modifier = modifier,
) )
} }
@ -237,6 +254,9 @@ fun RecommendedContent(
}, },
onSendMediaInfo = viewModel.mediaReportService::sendReportFor, onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = { showDeleteDialog = RowColumnItem(position, item) }, onClickDelete = { showDeleteDialog = RowColumnItem(position, item) },
onClickAddToQueue = {
viewModel.addToQueue(it, 0)
},
), ),
), ),
onDismissRequest = { moreDialog.makeAbsent() }, onDismissRequest = { moreDialog.makeAbsent() },

View file

@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.MusicService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SuggestionService import com.github.damontecres.wholphin.services.SuggestionService
import com.github.damontecres.wholphin.services.SuggestionsResource import com.github.damontecres.wholphin.services.SuggestionsResource
@ -55,7 +56,8 @@ class RecommendedMovieViewModel
@AssistedInject @AssistedInject
constructor( constructor(
@ApplicationContext context: Context, @ApplicationContext context: Context,
private val api: ApiClient, api: ApiClient,
musicService: MusicService,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val preferencesDataStore: DataStore<AppPreferences>, private val preferencesDataStore: DataStore<AppPreferences>,
private val suggestionService: SuggestionService, private val suggestionService: SuggestionService,
@ -67,9 +69,11 @@ class RecommendedMovieViewModel
mediaManagementService: MediaManagementService, mediaManagementService: MediaManagementService,
) : RecommendedViewModel( ) : RecommendedViewModel(
context, context,
api,
navigationManager, navigationManager,
favoriteWatchManager, favoriteWatchManager,
mediaReportService, mediaReportService,
musicService,
backdropService, backdropService,
mediaManagementService, mediaManagementService,
) { ) {

View file

@ -0,0 +1,252 @@
package com.github.damontecres.wholphin.ui.components
import android.content.Context
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.datastore.core.DataStore
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.MusicService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SuggestionService
import com.github.damontecres.wholphin.services.SuggestionsResource
import com.github.damontecres.wholphin.ui.AspectRatio
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.toBaseItems
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import timber.log.Timber
import java.util.UUID
@HiltViewModel(assistedFactory = RecommendedMusicViewModel.Factory::class)
class RecommendedMusicViewModel
@AssistedInject
constructor(
@ApplicationContext context: Context,
api: ApiClient,
musicService: MusicService,
private val serverRepository: ServerRepository,
private val preferencesDataStore: DataStore<AppPreferences>,
private val suggestionService: SuggestionService,
@Assisted val parentId: UUID,
navigationManager: NavigationManager,
favoriteWatchManager: FavoriteWatchManager,
mediaReportService: MediaReportService,
backdropService: BackdropService,
mediaManagementService: MediaManagementService,
) : RecommendedViewModel(
context,
api,
navigationManager,
favoriteWatchManager,
mediaReportService,
musicService,
backdropService,
mediaManagementService,
) {
@AssistedFactory
interface Factory {
fun create(parentId: UUID): RecommendedMusicViewModel
}
override val rows =
MutableStateFlow<List<HomeRowLoadingState>>(
rowTitles.keys.map {
HomeRowLoadingState.Pending(
context.getString(it),
)
},
)
override fun init() {
viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
val itemsPerRow =
preferencesDataStore.data
.firstOrNull()
?.homePagePreferences
?.maxItemsPerRow
?: AppPreference.HomePageItems.defaultValue.toInt()
val jobs = mutableListOf<Deferred<HomeRowLoadingState>>()
val viewOptions =
HomeRowViewOptions(
aspectRatio = AspectRatio.SQUARE,
heightDp = Cards.HEIGHT_EPISODE,
showTitles = true,
)
update(R.string.recently_released, viewOptions) {
val request =
GetItemsRequest(
parentId = parentId,
fields = SlimItemFields,
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
recursive = true,
enableUserData = true,
sortBy = listOf(ItemSortBy.PREMIERE_DATE),
sortOrder = listOf(SortOrder.DESCENDING),
startIndex = 0,
limit = itemsPerRow,
enableTotalRecordCount = false,
)
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
}.also(jobs::add)
update(R.string.recently_added, viewOptions) {
val request =
GetItemsRequest(
parentId = parentId,
fields = SlimItemFields,
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
recursive = true,
enableUserData = true,
sortBy = listOf(ItemSortBy.DATE_CREATED),
sortOrder = listOf(SortOrder.DESCENDING),
startIndex = 0,
limit = itemsPerRow,
enableTotalRecordCount = false,
)
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
}.also(jobs::add)
update(R.string.top_unwatched, viewOptions) {
val request =
GetItemsRequest(
parentId = parentId,
fields = SlimItemFields,
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
recursive = true,
enableUserData = true,
isPlayed = false,
sortBy = listOf(ItemSortBy.COMMUNITY_RATING),
sortOrder = listOf(SortOrder.DESCENDING),
startIndex = 0,
limit = itemsPerRow,
enableTotalRecordCount = false,
)
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
}.also(jobs::add)
viewModelScope.launch(Dispatchers.IO) {
try {
suggestionService
.getSuggestionsFlow(parentId, BaseItemKind.MUSIC_ALBUM)
.collect { resource ->
val state =
when (resource) {
is SuggestionsResource.Loading -> {
HomeRowLoadingState.Loading(
context.getString(R.string.suggestions),
)
}
is SuggestionsResource.Success -> {
HomeRowLoadingState.Success(
context.getString(R.string.suggestions),
resource.items,
)
}
is SuggestionsResource.Empty -> {
HomeRowLoadingState.Success(
context.getString(R.string.suggestions),
emptyList(),
)
}
}
update(R.string.suggestions, state)
}
} catch (ex: Exception) {
Timber.e(ex, "Failed to fetch suggestions")
update(
R.string.suggestions,
HomeRowLoadingState.Error(
title = context.getString(R.string.suggestions),
exception = ex,
),
)
}
}
for (i in 0..<jobs.size) {
val result = jobs[i].await()
if (result.completed) {
Timber.v("First success")
loading.setValueOnMain(LoadingState.Success)
}
break
}
}
}
override fun update(
@StringRes title: Int,
row: HomeRowLoadingState,
): HomeRowLoadingState {
rows.update { current ->
current.toMutableList().apply { set(rowTitles[title]!!, row) }
}
return row
}
companion object {
private val rowTitles =
listOf(
R.string.recently_released,
R.string.recently_added,
R.string.suggestions,
R.string.top_unwatched,
).mapIndexed { index, i -> i to index }.toMap()
}
}
@Composable
fun RecommendedMusic(
preferences: UserPreferences,
parentId: UUID,
onFocusPosition: (RowColumn) -> Unit,
modifier: Modifier = Modifier,
viewModel: RecommendedMusicViewModel =
hiltViewModel<RecommendedMusicViewModel, RecommendedMusicViewModel.Factory>(
creationCallback = { it.create(parentId) },
),
) {
RecommendedContent(
preferences = preferences,
viewModel = viewModel,
onFocusPosition = onFocusPosition,
modifier = modifier,
)
}

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