mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Merge branch 'main' into develop/ac3-test
This commit is contained in:
commit
ace714da7b
291 changed files with 17616 additions and 4635 deletions
82
.github/actions/native-build/action.yml
vendored
82
.github/actions/native-build/action.yml
vendored
|
|
@ -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 }}
|
||||
2
.github/actions/setup/action.yml
vendored
2
.github/actions/setup/action.yml
vendored
|
|
@ -21,7 +21,7 @@ runs:
|
|||
echo "BUILD_TOOLS_VERSION=36.0.0" >> $GITHUB_ENV
|
||||
echo "NDK_VERSION=29.0.14206865" >> $GITHUB_ENV
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
uses: android-actions/setup-android@v4
|
||||
with:
|
||||
packages: "tools platform-tools build-tools;${{ env.BUILD_TOOLS_VERSION }} ndk;${{ env.NDK_VERSION }}"
|
||||
- name: Add NDK to path
|
||||
|
|
|
|||
17
.github/workflows/main.yml
vendored
17
.github/workflows/main.yml
vendored
|
|
@ -30,10 +30,6 @@ jobs:
|
|||
fetch-depth: 0 # Need the tags to build
|
||||
- name: Setup
|
||||
uses: ./.github/actions/setup
|
||||
- name: Native build
|
||||
uses: ./.github/actions/native-build
|
||||
with:
|
||||
cache: true
|
||||
|
||||
- name: Get version names
|
||||
run: |
|
||||
|
|
@ -55,8 +51,11 @@ jobs:
|
|||
KEY_PASSWORD: "${{ secrets.KEY_PASSWORD }}"
|
||||
KEY_STORE_PASSWORD: "${{ secrets.KEY_STORE_PASSWORD }}"
|
||||
SIGNING_KEY: "${{ secrets.SIGNING_KEY }}"
|
||||
ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}"
|
||||
ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}"
|
||||
run: |
|
||||
./gradlew clean assembleRelease assembleDebug --no-daemon
|
||||
./gradlew clean assembleDefaultRelease assembleDefaultDebug --no-daemon
|
||||
|
||||
- name: Verify signatures
|
||||
run: |
|
||||
echo "Verify APK signatures"
|
||||
|
|
@ -68,12 +67,10 @@ jobs:
|
|||
find app/build/outputs/apk -name '*.apk' -print0 |
|
||||
while IFS= read -r -d '' line; do
|
||||
# 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"
|
||||
cp "$line" "$short_name"
|
||||
done
|
||||
apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//')
|
||||
echo "apks=$apks" >> "$GITHUB_OUTPUT"
|
||||
- name: Checksums
|
||||
run: |
|
||||
echo "SHA256 checksums:"
|
||||
|
|
@ -82,6 +79,8 @@ jobs:
|
|||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
shopt -s globstar
|
||||
|
||||
gh release delete "${{ env.TAG_NAME }}" --cleanup-tag -y || true
|
||||
|
||||
|
||||
|
|
@ -98,4 +97,4 @@ jobs:
|
|||
|
||||
gh release create "${{ env.TAG_NAME }}" \
|
||||
--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
|
||||
|
|
|
|||
19
.github/workflows/pr.yml
vendored
19
.github/workflows/pr.yml
vendored
|
|
@ -36,26 +36,9 @@ jobs:
|
|||
fetch-depth: 0 # Need the tags to build
|
||||
- name: Setup
|
||||
uses: ./.github/actions/setup
|
||||
- name: Native build
|
||||
uses: ./.github/actions/native-build
|
||||
with:
|
||||
cache: true
|
||||
- name: Build app
|
||||
id: buildapp
|
||||
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/,$//')
|
||||
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
|
||||
|
|
|
|||
30
.github/workflows/release.yml
vendored
30
.github/workflows/release.yml
vendored
|
|
@ -26,10 +26,6 @@ jobs:
|
|||
fetch-depth: 0 # Need the tags to build
|
||||
- name: Setup
|
||||
uses: ./.github/actions/setup
|
||||
- name: Native build
|
||||
uses: ./.github/actions/native-build
|
||||
with:
|
||||
cache: false
|
||||
- name: Build app
|
||||
id: buildapp
|
||||
env:
|
||||
|
|
@ -37,21 +33,11 @@ jobs:
|
|||
KEY_PASSWORD: "${{ secrets.KEY_PASSWORD }}"
|
||||
KEY_STORE_PASSWORD: "${{ secrets.KEY_STORE_PASSWORD }}"
|
||||
SIGNING_KEY: "${{ secrets.SIGNING_KEY }}"
|
||||
ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}"
|
||||
ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}"
|
||||
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
|
||||
run: |
|
||||
echo "Verify APK signatures"
|
||||
|
|
@ -62,13 +48,11 @@ jobs:
|
|||
run: |
|
||||
find app/build/outputs/apk -name '*.apk' -print0 |
|
||||
while IFS= read -r -d '' line; do
|
||||
# Wholphin-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]+//')"
|
||||
# Wholphin-default-release-0.2.9-62-g5c12e03-16-arm64-v8a.apk => Wholphin-arm64-v8a.apk
|
||||
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"
|
||||
cp "$line" "$short_name"
|
||||
done
|
||||
apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//')
|
||||
echo "apks=$apks" >> "$GITHUB_OUTPUT"
|
||||
- name: Checksums
|
||||
run: |
|
||||
echo "SHA256 checksums:"
|
||||
|
|
@ -84,6 +68,8 @@ jobs:
|
|||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
shopt -s globstar
|
||||
|
||||
gh release create "${{ env.TAG_NAME }}" \
|
||||
--latest --title "${{ env.TAG_NAME }}" --verify-tag -n "" --draft \
|
||||
"app/build/outputs/apk/**/*.apk"
|
||||
app/build/outputs/apk/**/*.apk
|
||||
|
|
|
|||
|
|
@ -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
|
||||
* [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
|
||||
* [MPV/libmpv](https://github.com/mpv-player/mpv) for media playback
|
||||
* [Coil](https://coil-kt.github.io/coil/) for image loading
|
||||
* [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
|
||||
- `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 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.
|
||||
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.
|
||||
|
||||
### App settings
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
- Use poster or thumb images, show/hide titles, add/remove/re-order different types of rows!
|
||||
- A navigation drawer for quick access to libraries, favorites, search, and settings from almost anywhere in the app
|
||||
- Integration with [Jellyseerr](https://github.com/seerr-team/seerr) to discover new movies and TV shows
|
||||
- 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
|
||||
- Show Movie/TV Show titles when browsing libraries
|
||||
- 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
|
||||
- Subtly show playback position along the bottom of the screen while seeking w/ D-Pad
|
||||
|
||||
|
||||
### Roadmap
|
||||
|
||||
See [here for the roadmap](https://github.com/damontecres/Wholphin/wiki#roadmap)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import com.android.build.api.dsl.ProductFlavor
|
||||
import com.google.protobuf.gradle.id
|
||||
import com.mikepenz.aboutlibraries.plugin.DuplicateMode
|
||||
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 ffmpegModuleExists = project.file("libs/lib-decoder-ffmpeg-release.aar").exists()
|
||||
val av1ModuleExists = project.file("libs/lib-decoder-av1-release.aar").exists()
|
||||
val mpvModuleExists = project.file("libs/wholphin-mpv-release.aar").exists()
|
||||
val extensionsRepoActive = project.hasProperty("WholphinExtensionsUsername")
|
||||
|
||||
val gitTags =
|
||||
providers
|
||||
|
|
@ -45,6 +48,8 @@ android {
|
|||
versionCode = gitTags.trim().lines().size
|
||||
versionName = gitDescribe.trim().removePrefix("v").ifBlank { "0.0.0" }
|
||||
testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner"
|
||||
|
||||
buildConfigField("long", "BUILD_TIME", System.currentTimeMillis().toString())
|
||||
}
|
||||
|
||||
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 {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
|
|
@ -134,6 +171,12 @@ android {
|
|||
isUniversalApk = true
|
||||
}
|
||||
}
|
||||
packaging {
|
||||
jniLibs {
|
||||
// Work around because libass-android & wholphin-mpv both (incorrectly) package libc++_shared.so
|
||||
pickFirsts += "lib/*/libc++_shared.so"
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
|
|
@ -146,6 +189,10 @@ android {
|
|||
isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
disable.add("MissingTranslation")
|
||||
}
|
||||
}
|
||||
|
||||
protobuf {
|
||||
|
|
@ -219,6 +266,7 @@ dependencies {
|
|||
implementation(libs.androidx.lifecycle.livedata.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.datastore)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.protobuf.kotlin.lite)
|
||||
implementation(libs.androidx.tvprovider)
|
||||
implementation(libs.androidx.work.runtime.ktx)
|
||||
|
|
@ -231,6 +279,7 @@ dependencies {
|
|||
implementation(libs.androidx.media3.exoplayer.dash)
|
||||
implementation(libs.androidx.media3.ui)
|
||||
implementation(libs.androidx.media3.ui.compose)
|
||||
implementation(libs.ass.media)
|
||||
|
||||
implementation(libs.coil.core)
|
||||
implementation(libs.coil.compose)
|
||||
|
|
@ -287,11 +336,33 @@ dependencies {
|
|||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
coreLibraryDesugaring(libs.desugar.jdk.libs)
|
||||
if (ffmpegModuleExists || isCI) {
|
||||
|
||||
if (ffmpegModuleExists) {
|
||||
logger.info("Using local ffmpeg decoder")
|
||||
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"))
|
||||
} 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)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ import androidx.compose.ui.test.onNodeWithTag
|
|||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
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.ScreensaverState
|
||||
import com.github.damontecres.wholphin.services.SetupDestination
|
||||
|
|
@ -43,16 +45,17 @@ class InstrumentedBasicUiTests {
|
|||
@OptIn(ExperimentalTestApi::class)
|
||||
@Test
|
||||
fun myTest() {
|
||||
val navigationManager = NavigationManager()
|
||||
navigationManager.backStack = NavBackStack(Destination.Home())
|
||||
// Start the app
|
||||
composeTestRule.setContent {
|
||||
WholphinTheme {
|
||||
MainContent(
|
||||
backStack = mutableListOf(SetupDestination.ServerList),
|
||||
navigationManager = mockk(relaxed = true),
|
||||
navigationManager = navigationManager,
|
||||
appPreferences = mockk(relaxed = true),
|
||||
backdropService = mockk(relaxed = true),
|
||||
screensaverService = screensaverService,
|
||||
requestedDestination = Destination.Home(),
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
7
app/src/appstore/AndroidManifest.xml
Normal file
7
app/src/appstore/AndroidManifest.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="false" />
|
||||
android:required="${leanback}" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.microphone"
|
||||
android:required="false" />
|
||||
|
|
@ -30,6 +30,10 @@
|
|||
<intent>
|
||||
<action android:name="android.speech.action.RECOGNIZE_SPEECH" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:mimeType="video/*" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.github.damontecres.wholphin
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
|
|
@ -24,15 +25,18 @@ import androidx.datastore.core.DataStore
|
|||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||
import com.github.damontecres.wholphin.services.AppUpgradeHandler
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.DatePlayedInvalidationService
|
||||
import com.github.damontecres.wholphin.services.DeviceProfileService
|
||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||
import com.github.damontecres.wholphin.services.LatestNextUpSchedulerService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver
|
||||
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.detail.series.SeasonEpisodeIds
|
||||
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.showToast
|
||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
|
||||
import com.github.damontecres.wholphin.util.DebugLogTree
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.catch
|
||||
|
|
@ -66,7 +71,9 @@ import kotlinx.coroutines.flow.firstOrNull
|
|||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
|
|
@ -111,6 +118,9 @@ class MainActivity : AppCompatActivity() {
|
|||
@Inject
|
||||
lateinit var suggestionsSchedulerService: SuggestionsSchedulerService
|
||||
|
||||
@Inject
|
||||
lateinit var latestNextUpSchedulerService: LatestNextUpSchedulerService
|
||||
|
||||
@Inject
|
||||
lateinit var backdropService: BackdropService
|
||||
|
||||
|
|
@ -127,6 +137,11 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
private var signInAuto = true
|
||||
|
||||
private val json =
|
||||
Json {
|
||||
classDiscriminator = "_type"
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
|
@ -134,6 +149,26 @@ class MainActivity : AppCompatActivity() {
|
|||
Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}")
|
||||
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 ->
|
||||
if (user?.hasPin == true) {
|
||||
window?.setFlags(
|
||||
|
|
@ -206,17 +241,12 @@ class MainActivity : AppCompatActivity() {
|
|||
appThemeColors = appPreferences.interfacePreferences.appThemeColors,
|
||||
) {
|
||||
ProvideLocalClock {
|
||||
val requestedDestination =
|
||||
remember(intent) {
|
||||
intent?.let(::extractDestination) ?: Destination.Home()
|
||||
}
|
||||
MainContent(
|
||||
backStack = setupNavigationManager.backStack,
|
||||
navigationManager = navigationManager,
|
||||
appPreferences = appPreferences,
|
||||
backdropService = backdropService,
|
||||
screensaverService = screensaverService,
|
||||
requestedDestination = requestedDestination,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
|
@ -287,6 +317,11 @@ class MainActivity : AppCompatActivity() {
|
|||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
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) {
|
||||
|
|
@ -362,6 +397,9 @@ class MainActivity : AppCompatActivity() {
|
|||
const val INTENT_SEASON_NUMBER = "seaNum"
|
||||
const val INTENT_SEASON_ID = "seaId"
|
||||
|
||||
private const val KEY_BACK_STACK = "backStack"
|
||||
private const val KEY_EXTERNAL_PLAYER = "extPlayer"
|
||||
|
||||
lateinit var instance: MainActivity
|
||||
private set
|
||||
}
|
||||
|
|
@ -371,6 +409,7 @@ class MainActivity : AppCompatActivity() {
|
|||
class MainActivityViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val preferences: DataStore<AppPreferences>,
|
||||
val serverRepository: ServerRepository,
|
||||
private val navigationManager: SetupNavigationManager,
|
||||
|
|
@ -379,9 +418,19 @@ class MainActivityViewModel
|
|||
private val appUpgradeHandler: AppUpgradeHandler,
|
||||
) : ViewModel() {
|
||||
fun appStart() {
|
||||
viewModelScope.launchIO {
|
||||
viewModelScope.launchDefault {
|
||||
try {
|
||||
val needUpgrade = appUpgradeHandler.needUpgrade()
|
||||
if (needUpgrade) {
|
||||
showToast(
|
||||
context,
|
||||
context.getString(
|
||||
R.string.updated_toast,
|
||||
appUpgradeHandler.currentVersion.toString(),
|
||||
),
|
||||
)
|
||||
appUpgradeHandler.run()
|
||||
}
|
||||
appUpgradeHandler.copySubfont(false)
|
||||
val prefs =
|
||||
preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
|
||||
|
|
@ -424,7 +473,7 @@ class MainActivityViewModel
|
|||
navigationManager.navigateTo(SetupDestination.ServerList)
|
||||
}
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
viewModelScope.launchDefault {
|
||||
// Create the mediaCodecCapabilitiesTest if needed
|
||||
deviceProfileService.mediaCodecCapabilitiesTest.supportsAVC()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import androidx.compose.runtime.collectAsState
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
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.ui.components.AppScreensaver
|
||||
import com.github.damontecres.wholphin.ui.nav.ApplicationContent
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
|
||||
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
|
||||
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
|
||||
|
||||
@Composable
|
||||
fun MainContent(
|
||||
|
|
@ -45,9 +44,9 @@ fun MainContent(
|
|||
appPreferences: AppPreferences,
|
||||
backdropService: BackdropService,
|
||||
screensaverService: ScreensaverService,
|
||||
requestedDestination: Destination,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val preferences by rememberUpdatedState(UserPreferences(appPreferences))
|
||||
Surface(
|
||||
modifier =
|
||||
modifier
|
||||
|
|
@ -96,10 +95,6 @@ fun MainContent(
|
|||
backdropService.clearBackdrop()
|
||||
}
|
||||
val current = key.current
|
||||
val preferences =
|
||||
remember(appPreferences) {
|
||||
UserPreferences(appPreferences)
|
||||
}
|
||||
var showContent by remember {
|
||||
mutableStateOf(true)
|
||||
}
|
||||
|
|
@ -113,7 +108,6 @@ fun MainContent(
|
|||
ApplicationContent(
|
||||
user = current.user,
|
||||
server = current.server,
|
||||
startDestination = requestedDestination,
|
||||
navigationManager = navigationManager,
|
||||
preferences = preferences,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
|
|
|
|||
|
|
@ -22,9 +22,7 @@ import androidx.savedstate.SavedStateRegistryOwner
|
|||
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.ScreensaverService
|
||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.ui.components.AppScreensaverContent
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
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.first
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
|
|
@ -61,6 +60,7 @@ class WholphinDreamService :
|
|||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Timber.d("onCreate")
|
||||
|
||||
savedStateRegistryController.performRestore(null)
|
||||
lifecycleRegistry.currentState = Lifecycle.State.CREATED
|
||||
|
|
@ -74,6 +74,7 @@ class WholphinDreamService :
|
|||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
Timber.d("onAttachedToWindow")
|
||||
val itemFlow = screensaverService.createItemFlow(lifecycleScope)
|
||||
setContentView(
|
||||
ComposeView(this).apply {
|
||||
|
|
@ -109,11 +110,13 @@ class WholphinDreamService :
|
|||
|
||||
override fun onDreamingStarted() {
|
||||
super.onDreamingStarted()
|
||||
Timber.d("onDreamingStarted")
|
||||
lifecycleRegistry.currentState = Lifecycle.State.STARTED
|
||||
}
|
||||
|
||||
override fun onDreamingStopped() {
|
||||
super.onDreamingStopped()
|
||||
Timber.d("onDreamingStopped")
|
||||
lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,12 +8,18 @@ import com.github.damontecres.wholphin.ui.nav.Destination
|
|||
import org.jellyfin.sdk.model.UUID
|
||||
import org.jellyfin.sdk.model.api.ExtraType
|
||||
|
||||
/**
|
||||
* Represents "extras" for media such as behind-the-scenes or deleted scenes
|
||||
*/
|
||||
sealed interface ExtrasItem {
|
||||
val parentId: UUID
|
||||
val type: ExtraType
|
||||
val destination: Destination
|
||||
val title: String?
|
||||
|
||||
/**
|
||||
* Represents multiple extras of the same type
|
||||
*/
|
||||
data class Group(
|
||||
override val parentId: UUID,
|
||||
override val type: ExtraType,
|
||||
|
|
@ -25,6 +31,9 @@ sealed interface ExtrasItem {
|
|||
override val title: String? = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a single extra
|
||||
*/
|
||||
data class Single(
|
||||
override val parentId: UUID,
|
||||
override val type: ExtraType,
|
||||
|
|
@ -38,6 +47,9 @@ sealed interface ExtrasItem {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts [ExtraType] to the string resource ID
|
||||
*/
|
||||
@get:StringRes
|
||||
val ExtraType.stringRes: Int
|
||||
get() =
|
||||
|
|
@ -56,6 +68,9 @@ val ExtraType.stringRes: Int
|
|||
ExtraType.SHORT -> R.string.shorts
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts [ExtraType] to the plural resource ID
|
||||
*/
|
||||
@get:PluralsRes
|
||||
val ExtraType.pluralRes: Int
|
||||
get() =
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import androidx.room.Query
|
|||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import java.util.UUID
|
||||
|
||||
@Dao
|
||||
|
|
@ -27,6 +28,12 @@ interface LibraryDisplayInfoDao {
|
|||
itemId: String,
|
||||
): LibraryDisplayInfo?
|
||||
|
||||
@Query("SELECT * from LibraryDisplayInfo WHERE userId=:userId AND itemId=:itemId")
|
||||
fun getItemAsFlow(
|
||||
userId: Int,
|
||||
itemId: String,
|
||||
): Flow<LibraryDisplayInfo?>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun saveItem(item: LibraryDisplayInfo): Long
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,19 @@ val DefaultFilterOptions =
|
|||
DecadeFilter,
|
||||
)
|
||||
|
||||
val DefaultTvFilterOptions =
|
||||
listOf(
|
||||
PlayedFilter,
|
||||
FavoriteFilter,
|
||||
GenreFilter,
|
||||
StudioFilter,
|
||||
CommunityRatingFilter,
|
||||
OfficialRatingFilter,
|
||||
VideoTypeFilter,
|
||||
YearFilter,
|
||||
DecadeFilter,
|
||||
)
|
||||
|
||||
val DefaultForFavoritesFilterOptions =
|
||||
listOf(
|
||||
PlayedFilter,
|
||||
|
|
@ -32,6 +45,19 @@ val DefaultForGenresFilterOptions =
|
|||
listOf(
|
||||
PlayedFilter,
|
||||
FavoriteFilter,
|
||||
StudioFilter,
|
||||
CommunityRatingFilter,
|
||||
OfficialRatingFilter,
|
||||
VideoTypeFilter,
|
||||
YearFilter,
|
||||
DecadeFilter,
|
||||
)
|
||||
|
||||
val DefaultForStudiosFilterOptions =
|
||||
listOf(
|
||||
PlayedFilter,
|
||||
FavoriteFilter,
|
||||
GenreFilter,
|
||||
CommunityRatingFilter,
|
||||
OfficialRatingFilter,
|
||||
VideoTypeFilter,
|
||||
|
|
@ -50,6 +76,11 @@ val DefaultPlaylistItemsOptions =
|
|||
DecadeFilter,
|
||||
)
|
||||
|
||||
/**
|
||||
* A way to filter libraries
|
||||
*
|
||||
* Gets and sets values within a [GetItemsFilter]
|
||||
*/
|
||||
sealed interface ItemFilterBy<T> {
|
||||
@get:StringRes
|
||||
val stringRes: Int
|
||||
|
|
@ -178,3 +209,16 @@ data object CommunityRatingFilter : ItemFilterBy<Int> {
|
|||
filter: GetItemsFilter,
|
||||
): 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -5,12 +5,13 @@ import androidx.compose.runtime.Immutable
|
|||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import com.github.damontecres.wholphin.ui.DateFormatter
|
||||
import com.github.damontecres.wholphin.ui.abbreviateNumber
|
||||
import com.github.damontecres.wholphin.ui.detail.CardGridItem
|
||||
import com.github.damontecres.wholphin.ui.detail.music.artistsString
|
||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
||||
import com.github.damontecres.wholphin.ui.dot
|
||||
import com.github.damontecres.wholphin.ui.formatDateTime
|
||||
import com.github.damontecres.wholphin.ui.getDateFormatter
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.playback.playable
|
||||
import com.github.damontecres.wholphin.ui.roundMinutes
|
||||
|
|
@ -28,11 +29,14 @@ import java.util.Locale
|
|||
import java.util.UUID
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* Wrapper for [BaseItemDto] with shortcuts for various UI elements
|
||||
*/
|
||||
@Serializable
|
||||
@Stable
|
||||
data class BaseItem(
|
||||
val data: BaseItemDto,
|
||||
val useSeriesForPrimary: Boolean,
|
||||
val useSeriesForPrimary: Boolean = false,
|
||||
val imageUrlOverride: String? = null,
|
||||
val destinationOverride: Destination? = null,
|
||||
) : CardGridItem {
|
||||
|
|
@ -57,6 +61,7 @@ data class BaseItem(
|
|||
when (type) {
|
||||
BaseItemKind.EPISODE -> data.seasonEpisode + " - " + name
|
||||
BaseItemKind.SERIES -> data.seriesProductionYears
|
||||
BaseItemKind.AUDIO -> listOfNotNull(data.album, artistsString).joinToString(" - ")
|
||||
else -> data.productionYear?.toString()
|
||||
}
|
||||
|
||||
|
|
@ -74,8 +79,7 @@ data class BaseItem(
|
|||
|
||||
val canDelete: Boolean get() = data.canDelete == true
|
||||
|
||||
@Transient
|
||||
val aspectRatio: Float? = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 }
|
||||
val aspectRatio: Float? get() = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 }
|
||||
|
||||
val indexNumber get() = data.indexNumber
|
||||
|
||||
|
|
@ -87,9 +91,11 @@ data class BaseItem(
|
|||
|
||||
val favorite get() = data.userData?.isFavorite ?: false
|
||||
|
||||
@Transient
|
||||
val timeRemainingOrRuntime: Duration? = data.timeRemaining ?: data.runTimeTicks?.ticks
|
||||
val timeRemainingOrRuntime: Duration? get() = data.timeRemaining ?: data.runTimeTicks?.ticks
|
||||
|
||||
/**
|
||||
* Contains pre computed UI elements that would be expensive to create on the main thread
|
||||
*/
|
||||
@Transient
|
||||
val ui =
|
||||
BaseItemUi(
|
||||
|
|
@ -116,7 +122,7 @@ data class BaseItem(
|
|||
buildList {
|
||||
if (type == BaseItemKind.EPISODE) {
|
||||
data.seasonEpisode?.let(::add)
|
||||
data.premiereDate?.let { add(DateFormatter.format(it)) }
|
||||
data.premiereDate?.let { add(getDateFormatter().format(it)) }
|
||||
} else if (type == BaseItemKind.SERIES) {
|
||||
data.seriesProductionYears?.let(::add)
|
||||
} else if (type == BaseItemKind.PHOTO) {
|
||||
|
|
@ -125,6 +131,9 @@ data class BaseItem(
|
|||
} else if (data.premiereDate != null) {
|
||||
add(data.premiereDate!!.toLocalDate().toString())
|
||||
}
|
||||
} else if (type == BaseItemKind.BOX_SET) {
|
||||
data.productionYear?.let { add(it.toString()) }
|
||||
data.childCount?.let { add("$it items") }
|
||||
} else {
|
||||
data.productionYear?.let { add(it.toString()) }
|
||||
}
|
||||
|
|
@ -173,6 +182,9 @@ data class BaseItem(
|
|||
it.dayOfMonth.toString().padStart(2, '0')
|
||||
}?.toIntOrNull()
|
||||
|
||||
/**
|
||||
* Convert this [BaseItem] into a [Destination] to navigate to its page in the app
|
||||
*/
|
||||
fun destination(index: Int? = null): Destination {
|
||||
if (destinationOverride != null) return destinationOverride
|
||||
val result =
|
||||
|
|
@ -223,6 +235,7 @@ data class BaseItem(
|
|||
}
|
||||
|
||||
companion object {
|
||||
@Deprecated("Use regular constructor instead")
|
||||
fun from(
|
||||
dto: BaseItemDto,
|
||||
api: ApiClient,
|
||||
|
|
@ -244,6 +257,9 @@ data class BaseItemUi(
|
|||
val quickDetails: AnnotatedString,
|
||||
)
|
||||
|
||||
/**
|
||||
* Create the special [Destination.FilteredCollection] for the given genre information
|
||||
*/
|
||||
fun createGenreDestination(
|
||||
genreId: UUID,
|
||||
genreName: String,
|
||||
|
|
@ -252,6 +268,7 @@ fun createGenreDestination(
|
|||
includeItemTypes: List<BaseItemKind>?,
|
||||
) = Destination.FilteredCollection(
|
||||
itemId = parentId,
|
||||
parentType = BaseItemKind.GENRE,
|
||||
filter =
|
||||
CollectionFolderFilter(
|
||||
nameOverride =
|
||||
|
|
@ -268,3 +285,31 @@ fun createGenreDestination(
|
|||
),
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ import org.jellyfin.sdk.model.api.ImageType
|
|||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* Represents a chapter within a video
|
||||
*/
|
||||
data class Chapter(
|
||||
val name: String?,
|
||||
val position: Duration,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
|||
import java.time.LocalDate
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* The type of a Seerr/Discover object with mapping to the Jellyfin [BaseItemKind]
|
||||
*/
|
||||
@Serializable
|
||||
enum class SeerrItemType(
|
||||
val baseItemKind: BaseItemKind?,
|
||||
|
|
@ -46,6 +49,9 @@ enum class SeerrItemType(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How available is a particular discovered item within the Jellyfin server
|
||||
*/
|
||||
@Serializable
|
||||
enum class SeerrAvailability(
|
||||
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
|
||||
@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(
|
||||
val criticRating: Int?,
|
||||
val audienceRating: Float?,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ import org.jellyfin.sdk.model.api.request.GetPersonsRequest
|
|||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Filter for a collection folder
|
||||
*/
|
||||
@Serializable
|
||||
data class CollectionFolderFilter(
|
||||
val nameOverride: String? = null,
|
||||
|
|
@ -24,6 +27,9 @@ data class CollectionFolderFilter(
|
|||
val useSavedLibraryDisplayInfo: Boolean = true,
|
||||
)
|
||||
|
||||
/**
|
||||
* A sort of simplified filter which can be [applyTo] a [GetItemsRequest] or [GetPersonsRequest] to add or remove filters
|
||||
*/
|
||||
@Serializable
|
||||
data class GetItemsFilter(
|
||||
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 {
|
||||
var newFilter = this
|
||||
|
|
@ -128,6 +134,9 @@ data class GetItemsFilter(
|
|||
isFavorite = favorite,
|
||||
)
|
||||
|
||||
/**
|
||||
* Merge another [GetItemsFilter] onto this one, replacing only unset values
|
||||
*/
|
||||
fun merge(filter: GetItemsFilter): GetItemsFilter =
|
||||
this.copy(
|
||||
favorite = favorite ?: filter.favorite,
|
||||
|
|
|
|||
|
|
@ -90,6 +90,18 @@ sealed interface HomeRowConfig {
|
|||
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
|
||||
*/
|
||||
|
|
@ -162,7 +174,7 @@ sealed interface HomeRowConfig {
|
|||
@SerialName("ByParent")
|
||||
data class ByParent(
|
||||
val parentId: UUID,
|
||||
val recursive: Boolean,
|
||||
val recursive: Boolean = false,
|
||||
val sort: SortAndDirection? = null,
|
||||
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
|
||||
) : HomeRowConfig {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ import kotlinx.serialization.UseSerializers
|
|||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Store the media source and audio/subtitle tracks chosen for a specific media item
|
||||
*
|
||||
*/
|
||||
@Entity(
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
|
|
|
|||
|
|
@ -9,6 +9,11 @@ import kotlinx.serialization.UseSerializers
|
|||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Store modifications to an audio/subtitle track in a media item
|
||||
*
|
||||
* For example, the subtitle delay
|
||||
*/
|
||||
@Entity(
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ import org.jellyfin.sdk.model.ServerVersion
|
|||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Represents a Jellyfin server
|
||||
*/
|
||||
@Entity(tableName = "servers")
|
||||
@Serializable
|
||||
data class JellyfinServer(
|
||||
|
|
@ -29,6 +32,9 @@ data class JellyfinServer(
|
|||
val serverVersion: ServerVersion? by lazy { version?.let(ServerVersion::fromString) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a Jellyfin user for a particular server
|
||||
*/
|
||||
@Entity(
|
||||
tableName = "users",
|
||||
foreignKeys = [
|
||||
|
|
@ -59,6 +65,9 @@ data class JellyfinUser(
|
|||
"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(
|
||||
@Embedded val server: JellyfinServer,
|
||||
@Relation(
|
||||
|
|
|
|||
|
|
@ -9,13 +9,20 @@ import androidx.room.Ignore
|
|||
import androidx.room.Index
|
||||
import com.github.damontecres.wholphin.ui.components.ViewOptions
|
||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import kotlinx.serialization.UseSerializers
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.SortOrder
|
||||
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(
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
|
|
@ -41,4 +48,13 @@ data class LibraryDisplayInfo(
|
|||
) {
|
||||
@Ignore @Transient
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ enum class NavPinType {
|
|||
UNPINNED,
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores preference information about nav drawer items such as its order and whether to show or put in More
|
||||
*/
|
||||
@Entity(
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
|
|
@ -12,6 +12,9 @@ import org.jellyfin.sdk.model.api.BaseItemPerson
|
|||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.PersonKind
|
||||
|
||||
/**
|
||||
* Represents a person in some media such as an actor or director
|
||||
*/
|
||||
@Stable
|
||||
data class Person(
|
||||
val id: UUID,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ import androidx.room.Entity
|
|||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Store effects applied to some media such as color or hue adjustments
|
||||
*/
|
||||
@Entity(tableName = "playback_effects", primaryKeys = ["jellyfinUserRowId", "itemId", "type"])
|
||||
data class PlaybackEffect(
|
||||
val jellyfinUserRowId: Int,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@ import kotlinx.serialization.UseSerializers
|
|||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
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(
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
|
|
|
|||
|
|
@ -6,6 +6,11 @@ import androidx.compose.runtime.setValue
|
|||
import org.jellyfin.sdk.model.api.MediaType
|
||||
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(
|
||||
items: List<BaseItem>,
|
||||
startIndex: Int = 0,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ package com.github.damontecres.wholphin.data.model
|
|||
|
||||
import com.github.damontecres.wholphin.services.SeerrUserConfig
|
||||
|
||||
/**
|
||||
* Permission levels for a user on a Seerr server
|
||||
*/
|
||||
enum class SeerrPermission(
|
||||
private val flag: Int,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ import kotlinx.serialization.Serializable
|
|||
import kotlinx.serialization.UseSerializers
|
||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
|
||||
/**
|
||||
* Represents a Seerr server instance
|
||||
*/
|
||||
@Entity(
|
||||
tableName = "seerr_servers",
|
||||
indices = [Index("url", unique = true)],
|
||||
|
|
@ -26,6 +29,9 @@ data class SeerrServer(
|
|||
val version: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents a user on a [SeerrServer]
|
||||
*/
|
||||
@Entity(
|
||||
tableName = "seerr_users",
|
||||
foreignKeys = [
|
||||
|
|
@ -56,12 +62,18 @@ data class SeerrUser(
|
|||
"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 {
|
||||
LOCAL,
|
||||
JELLYFIN,
|
||||
API_KEY,
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the relationship between a [SeerrServer] and its [SeerrUser]s
|
||||
*/
|
||||
data class SeerrServerUsers(
|
||||
@Embedded val server: SeerrServer,
|
||||
@Relation(
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
package com.github.damontecres.wholphin.data.model
|
||||
|
||||
/**
|
||||
* Represents a trailer for media
|
||||
*/
|
||||
sealed interface Trailer {
|
||||
val name: String
|
||||
}
|
||||
|
||||
/**
|
||||
* A [Trailer] stored on the Jellyfin server
|
||||
*/
|
||||
data class LocalTrailer(
|
||||
val baseItem: BaseItem,
|
||||
) : Trailer {
|
||||
|
|
@ -11,6 +17,9 @@ data class LocalTrailer(
|
|||
get() = baseItem.name ?: ""
|
||||
}
|
||||
|
||||
/**
|
||||
* A [Trailer] available via a remote URL, such as YouTube
|
||||
*/
|
||||
data class RemoteTrailer(
|
||||
override val name: String,
|
||||
val url: String,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import androidx.media3.effect.ScaleAndRotateTransformation
|
|||
import androidx.room.Ignore
|
||||
|
||||
/**
|
||||
* Modifications to a video playback
|
||||
* Modifications to an image or video playback
|
||||
*/
|
||||
data class VideoFilter(
|
||||
val rotation: Int = 0,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import androidx.annotation.ArrayRes
|
|||
import androidx.annotation.StringRes
|
||||
import androidx.core.content.edit
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.github.damontecres.wholphin.BuildConfig
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.WholphinApplication
|
||||
import com.github.damontecres.wholphin.services.UpdateChecker
|
||||
|
|
@ -62,7 +63,7 @@ sealed interface AppPreference<Pref, T> {
|
|||
AppSliderPreference<AppPreferences>(
|
||||
title = R.string.skip_forward_preference,
|
||||
defaultValue = 30,
|
||||
min = 10,
|
||||
min = 5,
|
||||
max = 5.minutes.inWholeSeconds,
|
||||
interval = 5,
|
||||
getter = {
|
||||
|
|
@ -464,16 +465,18 @@ sealed interface AppPreference<Pref, T> {
|
|||
summaryOn = R.string.enabled,
|
||||
summaryOff = R.string.disabled,
|
||||
)
|
||||
val DirectPlayAss =
|
||||
AppSwitchPreference<AppPreferences>(
|
||||
title = R.string.direct_play_ass,
|
||||
defaultValue = true,
|
||||
getter = { it.playbackPreferences.overrides.directPlayAss },
|
||||
val AssSubtitleMode =
|
||||
AppChoicePreference<AppPreferences, AssPlaybackMode>(
|
||||
title = R.string.ass_subtitle_playback,
|
||||
defaultValue = AssPlaybackMode.ASS_LIBASS,
|
||||
getter = { it.playbackPreferences.overrides.assPlaybackMode },
|
||||
setter = { prefs, value ->
|
||||
prefs.updatePlaybackOverrides { directPlayAss = value }
|
||||
prefs.updatePlaybackOverrides { assPlaybackMode = value }
|
||||
},
|
||||
summaryOn = R.string.enabled,
|
||||
summaryOff = R.string.disabled,
|
||||
displayValues = R.array.ass_subtitle_modes,
|
||||
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 =
|
||||
AppSwitchPreference<AppPreferences>(
|
||||
|
|
@ -535,6 +538,18 @@ sealed interface AppPreference<Pref, T> {
|
|||
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 =
|
||||
AppClickablePreference<AppPreferences>(
|
||||
title = R.string.installed_version,
|
||||
|
|
@ -845,8 +860,19 @@ sealed interface AppPreference<Pref, T> {
|
|||
},
|
||||
displayValues = R.array.player_backend_options,
|
||||
subtitles = R.array.player_backend_options_subtitles,
|
||||
indexToValue = { PlayerBackend.forNumber(it) },
|
||||
valueToIndex = { it.number },
|
||||
indexToValue = { PlayerBackend.forNumber(it) ?: PlayerBackend.EXO_PLAYER },
|
||||
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 =
|
||||
|
|
@ -1107,10 +1133,12 @@ val basicPreferences =
|
|||
PreferenceGroup(
|
||||
title = R.string.more,
|
||||
preferences =
|
||||
listOf(
|
||||
AppPreference.SeerrIntegration,
|
||||
AppPreference.AdvancedSettings,
|
||||
),
|
||||
buildList {
|
||||
if (BuildConfig.DISCOVER_ENABLED) {
|
||||
add(AppPreference.SeerrIntegration)
|
||||
}
|
||||
add(AppPreference.AdvancedSettings)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -1120,7 +1148,7 @@ private val ExoPlayerSettings =
|
|||
AppPreference.DownMixStereo,
|
||||
AppPreference.Ac3Supported,
|
||||
AppPreference.PreferAc3ForSurround,
|
||||
AppPreference.DirectPlayAss,
|
||||
AppPreference.AssSubtitleMode,
|
||||
AppPreference.DirectPlayPgs,
|
||||
AppPreference.DirectPlayDoviProfile7,
|
||||
AppPreference.DecodeAv1,
|
||||
|
|
@ -1157,6 +1185,7 @@ val advancedPreferences =
|
|||
preferences =
|
||||
listOf(
|
||||
AppPreference.ShowClock,
|
||||
AppPreference.ShowLogos,
|
||||
AppPreference.ManageMedia,
|
||||
AppPreference.CombineContinueNext,
|
||||
// Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418
|
||||
|
|
@ -1216,6 +1245,10 @@ val advancedPreferences =
|
|||
AppPreference.MpvSettings,
|
||||
),
|
||||
),
|
||||
ConditionalPreferences(
|
||||
{ it.playbackPreferences.playerBackend == PlayerBackend.EXTERNAL_PLAYER },
|
||||
listOf(AppPreference.ExternalPlayerApp),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -61,10 +61,11 @@ class AppPreferencesSerializer
|
|||
.apply {
|
||||
ac3Supported = AppPreference.Ac3Supported.defaultValue
|
||||
downmixStereo = AppPreference.DownMixStereo.defaultValue
|
||||
directPlayAss = AppPreference.DirectPlayAss.defaultValue
|
||||
// directPlayAss = AppPreference.DirectPlayAss.defaultValue
|
||||
directPlayPgs = AppPreference.DirectPlayPgs.defaultValue
|
||||
mediaExtensionsEnabled =
|
||||
AppPreference.FfmpegPreference.defaultValue
|
||||
assPlaybackMode = AppPreference.AssSubtitleMode.defaultValue
|
||||
}.build()
|
||||
|
||||
mpvOptions =
|
||||
|
|
@ -95,6 +96,7 @@ class AppPreferencesSerializer
|
|||
AppPreference.NavDrawerSwitchOnFocus.defaultValue
|
||||
showClock = AppPreference.ShowClock.defaultValue
|
||||
backdropStyle = AppPreference.BackdropStylePref.defaultValue
|
||||
showLogos = AppPreference.ShowLogos.defaultValue
|
||||
|
||||
subtitlesPreferences =
|
||||
SubtitlePreferences
|
||||
|
|
@ -152,6 +154,15 @@ class AppPreferencesSerializer
|
|||
slideshowDuration = AppPreference.SlideshowDuration.defaultValue
|
||||
slideshowPlayVideos = AppPreference.SlideshowPlayVideos.defaultValue
|
||||
}.build()
|
||||
|
||||
musicPreferences =
|
||||
MusicPreferences
|
||||
.newBuilder()
|
||||
.apply {
|
||||
showBackdrop = true
|
||||
showLyrics = true
|
||||
showAlbumArt = true
|
||||
}.build()
|
||||
}.build()
|
||||
|
||||
override suspend fun readFrom(input: InputStream): AppPreferences {
|
||||
|
|
@ -220,6 +231,11 @@ inline fun AppPreferences.updateScreensaverPreferences(block: ScreensaverPrefere
|
|||
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() {
|
||||
fontSize = SubtitleSettings.FontSize.defaultValue.toInt()
|
||||
fontColor = SubtitleSettings.FontColor.defaultValue.toArgb()
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageInfo
|
||||
import android.os.Build
|
||||
import androidx.core.content.edit
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.github.damontecres.wholphin.WholphinApplication
|
||||
import com.github.damontecres.wholphin.data.SeerrServerDao
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
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.updateLiveTvPreferences
|
||||
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.updatePlaybackOverrides
|
||||
import com.github.damontecres.wholphin.preferences.updateScreensaverPreferences
|
||||
|
|
@ -31,6 +32,9 @@ import java.io.File
|
|||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Handles any changes needed when the app in upgraded on the device such as setting new preferences
|
||||
*/
|
||||
@Singleton
|
||||
class AppUpgradeHandler
|
||||
@Inject
|
||||
|
|
@ -39,12 +43,10 @@ class AppUpgradeHandler
|
|||
private val appPreferences: DataStore<AppPreferences>,
|
||||
private val seerrServerDao: SeerrServerDao,
|
||||
) {
|
||||
suspend fun run() {
|
||||
val pkgInfo =
|
||||
WholphinApplication.instance.packageManager.getPackageInfo(
|
||||
WholphinApplication.instance.packageName,
|
||||
0,
|
||||
)
|
||||
val pkgInfo: PackageInfo get() = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
val currentVersion: Version get() = Version.fromString(pkgInfo.versionName!!)
|
||||
|
||||
fun needUpgrade(): Boolean {
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
val previousVersion = prefs.getString(VERSION_NAME_CURRENT_KEY, null)
|
||||
val previousVersionCode = prefs.getLong(VERSION_CODE_CURRENT_KEY, -1)
|
||||
|
|
@ -56,10 +58,26 @@ class AppUpgradeHandler
|
|||
} else {
|
||||
pkgInfo.versionCode.toLong()
|
||||
}
|
||||
if (newVersion != previousVersion || newVersionCode != previousVersionCode) {
|
||||
Timber.i(
|
||||
"App updated: $previousVersion=>$newVersion, $previousVersionCode=>$newVersionCode",
|
||||
"App versions: $previousVersion=>$newVersion, $previousVersionCode=>$newVersionCode",
|
||||
)
|
||||
return newVersion != previousVersion || newVersionCode != previousVersionCode
|
||||
}
|
||||
|
||||
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)
|
||||
|
|
@ -77,11 +95,11 @@ class AppUpgradeHandler
|
|||
Timber.e(ex, "Exception during app upgrade")
|
||||
}
|
||||
Timber.i("App upgrade complete")
|
||||
} else {
|
||||
Timber.d("No app update needed")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the font file used by MPV subtitles to the app's files directory
|
||||
*/
|
||||
fun copySubfont(overwrite: Boolean) {
|
||||
try {
|
||||
val fontFileName = "subfont.ttf"
|
||||
|
|
@ -110,6 +128,9 @@ class AppUpgradeHandler
|
|||
const val VERSION_CODE_CURRENT_KEY = "version.current.code"
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform any needed upgrades
|
||||
*/
|
||||
suspend fun upgradeApp(
|
||||
previous: Version,
|
||||
current: Version,
|
||||
|
|
@ -120,7 +141,7 @@ class AppUpgradeHandler
|
|||
it.updatePlaybackOverrides {
|
||||
ac3Supported = AppPreference.Ac3Supported.defaultValue
|
||||
downmixStereo = AppPreference.DownMixStereo.defaultValue
|
||||
directPlayAss = AppPreference.DirectPlayAss.defaultValue
|
||||
// directPlayAss = AppPreference.DirectPlayAss.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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,11 @@ import timber.log.Timber
|
|||
import javax.inject.Inject
|
||||
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
|
||||
@OptIn(FlowPreview::class)
|
||||
class BackdropService
|
||||
|
|
@ -46,6 +51,9 @@ class BackdropService
|
|||
private val _backdropFlow = MutableStateFlow<BackdropResult>(BackdropResult.NONE)
|
||||
val backdropFlow = _backdropFlow
|
||||
|
||||
/**
|
||||
* Update the backdrop to use the specified item
|
||||
*/
|
||||
suspend fun submit(item: BaseItem) =
|
||||
withContext(Dispatchers.IO) {
|
||||
val imageUrl =
|
||||
|
|
@ -57,8 +65,14 @@ class BackdropService
|
|||
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)
|
||||
|
||||
/**
|
||||
* Update the backdrop to use the specified URL
|
||||
*/
|
||||
suspend fun submit(
|
||||
itemId: String,
|
||||
imageUrl: String?,
|
||||
|
|
@ -74,6 +88,9 @@ class BackdropService
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the backdrop, such as when switching pages
|
||||
*/
|
||||
suspend fun clearBackdrop() {
|
||||
_backdropFlow.update {
|
||||
BackdropResult.NONE
|
||||
|
|
@ -114,7 +131,7 @@ class BackdropService
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun extractColorsFromBackdrop(imageUrl: String?): ExtractedColors =
|
||||
suspend fun extractColorsFromBackdrop(imageUrl: String?): ExtractedColors =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (imageUrl.isNullOrBlank()) {
|
||||
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(
|
||||
val itemId: String?,
|
||||
val imageUrl: String?,
|
||||
|
|
@ -245,6 +265,9 @@ data class BackdropResult(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The colors extracted from an image
|
||||
*/
|
||||
data class ExtractedColors(
|
||||
val primary: Color,
|
||||
val secondary: Color,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ import java.util.concurrent.TimeUnit
|
|||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Caches when media was lasted played. This is mostly used by the combined Continue Watching & Next Up home row.
|
||||
*/
|
||||
@Singleton
|
||||
class DatePlayedService
|
||||
@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? =
|
||||
withContext(Dispatchers.IO) {
|
||||
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) {
|
||||
item.data.seriesId?.let { 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) {
|
||||
val seriesId =
|
||||
api.userLibraryApi.getItem(itemId = itemId).content.let {
|
||||
|
|
@ -121,6 +138,9 @@ class DatePlayedService
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidates the date played cached when the current user changes
|
||||
*/
|
||||
@ActivityScoped
|
||||
class DatePlayedInvalidationService
|
||||
@Inject
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import com.github.damontecres.wholphin.preferences.AssPlaybackMode
|
||||
import com.github.damontecres.wholphin.preferences.PlaybackPreferences
|
||||
import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest
|
||||
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.Singleton
|
||||
|
||||
/**
|
||||
* Creates and caches the device direct play/transcoding profile sent to the server for ExoPlayer
|
||||
*/
|
||||
@Singleton
|
||||
class DeviceProfileService
|
||||
@Inject
|
||||
|
|
@ -21,7 +25,7 @@ class DeviceProfileService
|
|||
@param:ApplicationContext private val context: Context,
|
||||
) {
|
||||
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)
|
||||
}
|
||||
private val mutex = Mutex()
|
||||
|
|
@ -33,14 +37,14 @@ class DeviceProfileService
|
|||
prefs: PlaybackPreferences,
|
||||
serverVersion: ServerVersion?,
|
||||
): DeviceProfile =
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(Dispatchers.Default) {
|
||||
mutex.withLock {
|
||||
val newConfig =
|
||||
DeviceProfileConfiguration(
|
||||
maxBitrate = prefs.maxBitrate.toInt(),
|
||||
isAC3Enabled = prefs.overrides.ac3Supported,
|
||||
downMixAudio = prefs.overrides.downmixStereo,
|
||||
assDirectPlay = prefs.overrides.directPlayAss,
|
||||
assPlaybackMode = prefs.overrides.assPlaybackMode,
|
||||
pgsDirectPlay = prefs.overrides.directPlayPgs,
|
||||
dolbyVisionELDirectPlay = prefs.overrides.directPlayDolbyVisionEL,
|
||||
decodeAv1 = prefs.overrides.decodeAv1,
|
||||
|
|
@ -56,7 +60,7 @@ class DeviceProfileService
|
|||
maxBitrate = newConfig.maxBitrate,
|
||||
isAC3Enabled = newConfig.isAC3Enabled,
|
||||
downMixAudio = newConfig.downMixAudio,
|
||||
assDirectPlay = newConfig.assDirectPlay,
|
||||
assDirectPlay = newConfig.assPlaybackMode != AssPlaybackMode.ASS_TRANSCODE,
|
||||
pgsDirectPlay = newConfig.pgsDirectPlay,
|
||||
dolbyVisionELDirectPlay = newConfig.dolbyVisionELDirectPlay,
|
||||
decodeAv1 = prefs.overrides.decodeAv1,
|
||||
|
|
@ -76,7 +80,7 @@ data class DeviceProfileConfiguration(
|
|||
val maxBitrate: Int,
|
||||
val isAC3Enabled: Boolean,
|
||||
val downMixAudio: Boolean,
|
||||
val assDirectPlay: Boolean,
|
||||
val assPlaybackMode: AssPlaybackMode,
|
||||
val pgsDirectPlay: Boolean,
|
||||
val dolbyVisionELDirectPlay: Boolean,
|
||||
val decodeAv1: Boolean,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -9,12 +9,20 @@ import java.util.UUID
|
|||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Get extras for media
|
||||
*
|
||||
* @see [ExtrasItem]
|
||||
*/
|
||||
@Singleton
|
||||
class ExtrasService
|
||||
@Inject
|
||||
constructor(
|
||||
private val api: ApiClient,
|
||||
) {
|
||||
/**
|
||||
* Get the [ExtrasItem]s for the given item
|
||||
*/
|
||||
suspend fun getExtras(itemId: UUID): List<ExtrasItem> {
|
||||
val extrasMap =
|
||||
api.userLibraryApi
|
||||
|
|
@ -42,6 +50,9 @@ class ExtrasService
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The order which extras should be shown
|
||||
*/
|
||||
private val ExtraType.sortOrder: Int
|
||||
get() =
|
||||
when (this) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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.extensions.playStateApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
|
|
@ -8,6 +9,9 @@ import java.util.UUID
|
|||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Handles toggling media as favorited or watched
|
||||
*/
|
||||
@Singleton
|
||||
class FavoriteWatchManager
|
||||
@Inject
|
||||
|
|
@ -36,4 +40,7 @@ class FavoriteWatchManager
|
|||
} else {
|
||||
api.userLibraryApi.unmarkFavoriteItem(itemId).content
|
||||
}
|
||||
|
||||
suspend fun removeContinueWatching(item: BaseItem) {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.SUPPORTED_HOME_PAGE_SETTINGS_VERSION
|
||||
import com.github.damontecres.wholphin.data.model.createGenreDestination
|
||||
import com.github.damontecres.wholphin.data.model.createStudioDestination
|
||||
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
|
||||
import com.github.damontecres.wholphin.preferences.HomePagePreferences
|
||||
import com.github.damontecres.wholphin.ui.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.GetItemsRequestHandler
|
||||
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.Error
|
||||
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.jsonPrimitive
|
||||
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.userApi
|
||||
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.GetRecommendedProgramsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetRecordingsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetStudiosRequest
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Handles getting home page settings and data
|
||||
*/
|
||||
@Singleton
|
||||
class HomeSettingsService
|
||||
@Inject
|
||||
|
|
@ -75,6 +80,7 @@ class HomeSettingsService
|
|||
private val latestNextUpService: LatestNextUpService,
|
||||
private val imageUrlService: ImageUrlService,
|
||||
private val suggestionService: SuggestionService,
|
||||
private val displayPreferencesService: DisplayPreferencesService,
|
||||
) {
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
val jsonParser =
|
||||
|
|
@ -84,6 +90,9 @@ class HomeSettingsService
|
|||
allowTrailingComma = true
|
||||
}
|
||||
|
||||
/**
|
||||
* The current home page settings
|
||||
*/
|
||||
val currentSettings = MutableStateFlow(HomePageResolvedSettings.EMPTY)
|
||||
|
||||
/**
|
||||
|
|
@ -94,19 +103,11 @@ class HomeSettingsService
|
|||
suspend fun saveToServer(
|
||||
userId: UUID,
|
||||
settings: HomePageSettings,
|
||||
displayPreferencesId: String = DISPLAY_PREF_ID,
|
||||
displayPreferencesId: String = DisplayPreferencesService.DEFAULT_DISPLAY_PREF_ID,
|
||||
) {
|
||||
val current = getDisplayPreferences(userId, DISPLAY_PREF_ID)
|
||||
val customPrefs =
|
||||
current.customPrefs.toMutableMap().apply {
|
||||
displayPreferencesService.updateDisplayPreferences(userId, displayPreferencesId) {
|
||||
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(
|
||||
userId: UUID,
|
||||
displayPreferencesId: String = DISPLAY_PREF_ID,
|
||||
): HomePageSettings? {
|
||||
val current = getDisplayPreferences(userId, displayPreferencesId)
|
||||
return current.customPrefs[CUSTOM_PREF_ID]?.let {
|
||||
displayPreferencesId: String = DisplayPreferencesService.DEFAULT_DISPLAY_PREF_ID,
|
||||
): HomePageSettings? =
|
||||
displayPreferencesService
|
||||
.getDisplayPreferences(userId, displayPreferencesId)
|
||||
.customPrefs[CUSTOM_PREF_ID]
|
||||
?.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]
|
||||
|
|
@ -245,6 +237,9 @@ class HomeSettingsService
|
|||
currentSettings.update { resolvedSettings }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the settings and set them to be the current settings
|
||||
*/
|
||||
suspend fun updateCurrent(settings: HomePageSettings) {
|
||||
val resolvedRows =
|
||||
settings.rows.mapIndexed { index, config ->
|
||||
|
|
@ -317,14 +312,17 @@ class HomeSettingsService
|
|||
return HomePageResolvedSettings(rowConfig)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create home page settings from the user's web UI home page settings
|
||||
*/
|
||||
suspend fun parseFromWebConfig(userId: UUID): HomePageResolvedSettings? {
|
||||
val customPrefs =
|
||||
api.displayPreferencesApi
|
||||
displayPreferencesService
|
||||
.getDisplayPreferences(
|
||||
displayPreferencesId = "usersettings",
|
||||
userId = userId,
|
||||
client = "emby",
|
||||
).content.customPrefs
|
||||
).customPrefs
|
||||
val userDto by api.userApi.getUserById(userId)
|
||||
val config = userDto.configuration ?: DefaultUserConfiguration
|
||||
val libraries =
|
||||
|
|
@ -438,10 +436,7 @@ class HomeSettingsService
|
|||
): HomeRowConfigDisplay =
|
||||
when (config) {
|
||||
is HomeRowConfig.ByParent -> {
|
||||
val name =
|
||||
api.userLibraryApi
|
||||
.getItem(itemId = config.parentId)
|
||||
.content.name ?: ""
|
||||
val name = getItemName(config.parentId) ?: ""
|
||||
HomeRowConfigDisplay(
|
||||
id,
|
||||
name,
|
||||
|
|
@ -466,10 +461,7 @@ class HomeSettingsService
|
|||
}
|
||||
|
||||
is HomeRowConfig.Genres -> {
|
||||
val name =
|
||||
api.userLibraryApi
|
||||
.getItem(itemId = config.parentId)
|
||||
.content.name ?: ""
|
||||
val name = getItemName(config.parentId) ?: ""
|
||||
HomeRowConfigDisplay(
|
||||
id,
|
||||
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 -> {
|
||||
HomeRowConfigDisplay(id, config.name, config)
|
||||
}
|
||||
|
|
@ -490,10 +491,7 @@ class HomeSettingsService
|
|||
}
|
||||
|
||||
is HomeRowConfig.RecentlyAdded -> {
|
||||
val name =
|
||||
api.userLibraryApi
|
||||
.getItem(itemId = config.parentId)
|
||||
.content.name ?: ""
|
||||
val name = getItemName(config.parentId) ?: ""
|
||||
HomeRowConfigDisplay(
|
||||
id,
|
||||
context.getString(R.string.recently_added_in, name),
|
||||
|
|
@ -502,10 +500,7 @@ class HomeSettingsService
|
|||
}
|
||||
|
||||
is HomeRowConfig.RecentlyReleased -> {
|
||||
val name =
|
||||
api.userLibraryApi
|
||||
.getItem(itemId = config.parentId)
|
||||
.content.name ?: ""
|
||||
val name = getItemName(config.parentId) ?: ""
|
||||
HomeRowConfigDisplay(
|
||||
id,
|
||||
context.getString(R.string.recently_released_in, name),
|
||||
|
|
@ -547,10 +542,7 @@ class HomeSettingsService
|
|||
}
|
||||
|
||||
is HomeRowConfig.Suggestions -> {
|
||||
val name =
|
||||
api.userLibraryApi
|
||||
.getItem(itemId = config.parentId)
|
||||
.content.name ?: ""
|
||||
val name = getItemName(config.parentId) ?: ""
|
||||
HomeRowConfigDisplay(
|
||||
id = id,
|
||||
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]
|
||||
*/
|
||||
|
|
@ -585,6 +587,7 @@ class HomeSettingsService
|
|||
title = context.getString(R.string.continue_watching),
|
||||
items = resume,
|
||||
viewOptions = row.viewOptions,
|
||||
rowType = row,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -603,6 +606,7 @@ class HomeSettingsService
|
|||
title = context.getString(R.string.next_up),
|
||||
items = nextUp,
|
||||
viewOptions = row.viewOptions,
|
||||
rowType = row,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -632,6 +636,7 @@ class HomeSettingsService
|
|||
nextUp,
|
||||
),
|
||||
viewOptions = row.viewOptions,
|
||||
rowType = row,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -691,6 +696,58 @@ class HomeSettingsService
|
|||
title,
|
||||
genres,
|
||||
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,
|
||||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
)
|
||||
}
|
||||
latest
|
||||
|
|
@ -750,6 +808,7 @@ class HomeSettingsService
|
|||
title,
|
||||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -778,6 +837,7 @@ class HomeSettingsService
|
|||
name ?: context.getString(R.string.collection),
|
||||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -805,6 +865,7 @@ class HomeSettingsService
|
|||
row.name,
|
||||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -855,6 +916,7 @@ class HomeSettingsService
|
|||
title,
|
||||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -879,6 +941,7 @@ class HomeSettingsService
|
|||
context.getString(R.string.active_recordings),
|
||||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -891,7 +954,7 @@ class HomeSettingsService
|
|||
limit = limit,
|
||||
enableUserData = true,
|
||||
enableImages = true,
|
||||
enableImageTypes = listOf(ImageType.PRIMARY),
|
||||
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.LOGO),
|
||||
imageTypeLimit = 1,
|
||||
)
|
||||
api.liveTvApi
|
||||
|
|
@ -903,6 +966,7 @@ class HomeSettingsService
|
|||
context.getString(R.string.live_tv),
|
||||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -920,6 +984,7 @@ class HomeSettingsService
|
|||
context.getString(R.string.channels),
|
||||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -942,12 +1007,14 @@ class HomeSettingsService
|
|||
title,
|
||||
suggestions.items,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
)
|
||||
} else if (suggestions is SuggestionsResource.Empty) {
|
||||
Success(
|
||||
title,
|
||||
listOf(),
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
)
|
||||
} else {
|
||||
Error(
|
||||
|
|
@ -959,7 +1026,6 @@ class HomeSettingsService
|
|||
}
|
||||
|
||||
companion object {
|
||||
const val DISPLAY_PREF_ID = "default"
|
||||
const val CUSTOM_PREF_ID = "home_settings"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,30 +1,32 @@
|
|||
@file:UseSerializers(
|
||||
UUIDSerializer::class,
|
||||
LocalDateTimeSerializer::class,
|
||||
)
|
||||
|
||||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||
import com.github.damontecres.wholphin.util.LocalDateTimeSerializer
|
||||
import com.github.damontecres.wholphin.util.supportItemKinds
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
import 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.extensions.itemsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.UserDto
|
||||
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.SortOrder
|
||||
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
|
||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
import timber.log.Timber
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
|
@ -32,14 +34,21 @@ import javax.inject.Inject
|
|||
import javax.inject.Singleton
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Get continue watching and next up items for users
|
||||
*/
|
||||
@Singleton
|
||||
class LatestNextUpService
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val api: ApiClient,
|
||||
private val datePlayedService: DatePlayedService,
|
||||
private val displayPreferencesService: DisplayPreferencesService,
|
||||
private val favoriteWatchManager: FavoriteWatchManager,
|
||||
) {
|
||||
/**
|
||||
* Get resume (continue watching) items for a user
|
||||
*/
|
||||
suspend fun getResume(
|
||||
userId: UUID,
|
||||
limit: Int,
|
||||
|
|
@ -61,12 +70,6 @@ class LatestNextUpService
|
|||
remove(BaseItemKind.EPISODE)
|
||||
}
|
||||
},
|
||||
enableImageTypes =
|
||||
listOf(
|
||||
ImageType.PRIMARY,
|
||||
ImageType.THUMB,
|
||||
ImageType.BACKDROP,
|
||||
),
|
||||
)
|
||||
val items =
|
||||
api.itemsApi
|
||||
|
|
@ -77,6 +80,9 @@ class LatestNextUpService
|
|||
return items
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next up items for a user
|
||||
*/
|
||||
suspend fun getNextUp(
|
||||
userId: UUID,
|
||||
limit: Int,
|
||||
|
|
@ -85,6 +91,7 @@ class LatestNextUpService
|
|||
maxDays: Int,
|
||||
useSeriesForPrimary: Boolean = true,
|
||||
): List<BaseItem> {
|
||||
val removedSeries = getRemovedFromNextUp(userId)
|
||||
val nextUpDateCutoff =
|
||||
maxDays.takeIf { it > 0 }?.let { LocalDateTime.now().minusDays(it.toLong()) }
|
||||
val request =
|
||||
|
|
@ -105,68 +112,31 @@ class LatestNextUpService
|
|||
.content
|
||||
.items
|
||||
.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
|
||||
}
|
||||
|
||||
suspend fun getLatest(
|
||||
user: UserDto,
|
||||
limit: Int,
|
||||
includedIds: List<UUID>,
|
||||
): List<LatestData> {
|
||||
val excluded = user.configuration?.latestItemsExcludes.orEmpty()
|
||||
val views by api.userViewsApi.getUserViews()
|
||||
val latestData =
|
||||
views.items
|
||||
.filter {
|
||||
it.id in includedIds && it.id !in excluded &&
|
||||
it.collectionType in supportedLatestCollectionTypes
|
||||
}.map { view ->
|
||||
val title =
|
||||
view.name?.let { context.getString(R.string.recently_added_in, it) }
|
||||
?: context.getString(R.string.recently_added)
|
||||
val request =
|
||||
GetLatestMediaRequest(
|
||||
fields = SlimItemFields,
|
||||
imageTypeLimit = 1,
|
||||
parentId = view.id,
|
||||
groupItems = true,
|
||||
limit = limit,
|
||||
isPlayed = null, // Server will handle user's preference
|
||||
)
|
||||
LatestData(title, request)
|
||||
}
|
||||
|
||||
return latestData
|
||||
}
|
||||
|
||||
suspend fun loadLatest(latestData: List<LatestData>): List<HomeRowLoadingState> {
|
||||
val rows =
|
||||
latestData.mapNotNull { (title, request) ->
|
||||
try {
|
||||
val latest =
|
||||
api.userLibraryApi
|
||||
.getLatestMedia(request)
|
||||
.content
|
||||
.map { BaseItem.from(it, api, true) }
|
||||
if (latest.isNotEmpty()) {
|
||||
HomeRowLoadingState.Success(
|
||||
title = title,
|
||||
items = latest,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Exception fetching %s", title)
|
||||
HomeRowLoadingState.Error(
|
||||
title = title,
|
||||
exception = ex,
|
||||
)
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the combined Continue Watching & Next Up items
|
||||
*
|
||||
* @see [DatePlayedService]
|
||||
*/
|
||||
suspend fun buildCombined(
|
||||
resume: List<BaseItem>,
|
||||
nextUp: List<BaseItem>,
|
||||
|
|
@ -199,18 +169,112 @@ class LatestNextUpService
|
|||
Timber.v("buildCombined took %s", duration)
|
||||
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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val supportedLatestCollectionTypes =
|
||||
setOf(
|
||||
CollectionType.MOVIES,
|
||||
CollectionType.TVSHOWS,
|
||||
CollectionType.HOMEVIDEOS,
|
||||
// Exclude Live TV because a recording folder view will be used instead
|
||||
null, // Recordings & mixed collection types
|
||||
)
|
||||
/**
|
||||
* 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()
|
||||
|
||||
data class LatestData(
|
||||
val title: String,
|
||||
val request: GetLatestMediaRequest,
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class RemovedSeriesIds(
|
||||
val value: Map<UUID, LocalDateTime>,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,9 @@ import timber.log.Timber
|
|||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Service to manage media such as deletions
|
||||
*/
|
||||
@Singleton
|
||||
class MediaManagementService
|
||||
@Inject
|
||||
|
|
@ -45,6 +48,9 @@ class MediaManagementService
|
|||
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(
|
||||
item: BaseItem,
|
||||
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 {
|
||||
try {
|
||||
Timber.i("Deleting %s", item.id)
|
||||
// TODO enable
|
||||
api.libraryApi.deleteItem(item.id)
|
||||
_deletedItemFlow.emit(DeletedItem(item))
|
||||
return DeleteResult.Success
|
||||
|
|
@ -88,6 +98,9 @@ sealed interface DeleteResult {
|
|||
) : DeleteResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to delete an item and show a Toast based on success or error
|
||||
*/
|
||||
fun ViewModel.deleteItem(
|
||||
context: Context,
|
||||
mediaManagementService: MediaManagementService,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ import timber.log.Timber
|
|||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Send media info to the server
|
||||
*/
|
||||
@Singleton
|
||||
class MediaReportService
|
||||
@Inject
|
||||
|
|
@ -39,6 +42,9 @@ class MediaReportService
|
|||
encodeDefaults = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the media info and send it to the server
|
||||
*/
|
||||
fun sendReportFor(itemId: UUID) {
|
||||
ioScope.launchIO(ExceptionHandler(autoToast = true)) {
|
||||
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) {
|
||||
val sources =
|
||||
item.mediaSources ?: api.userLibraryApi
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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.NavPinType
|
||||
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.nav.Destination
|
||||
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 kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
|
|
@ -33,7 +36,11 @@ import timber.log.Timber
|
|||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
|
||||
/**
|
||||
* Gets the items to show in the nav drawer
|
||||
*/
|
||||
@Singleton
|
||||
class NavDrawerService
|
||||
@Inject
|
||||
|
|
@ -44,11 +51,13 @@ class NavDrawerService
|
|||
private val serverRepository: ServerRepository,
|
||||
private val serverPreferencesDao: ServerPreferencesDao,
|
||||
private val seerrServerRepository: SeerrServerRepository,
|
||||
private val musicService: MusicService,
|
||||
) {
|
||||
private val _state = MutableStateFlow(NavDrawerItemState.EMPTY)
|
||||
val state: StateFlow<NavDrawerItemState> = _state
|
||||
|
||||
init {
|
||||
// Handle updating the nav drawer when the user changes
|
||||
serverRepository.currentUser
|
||||
.asFlow()
|
||||
.combine(serverRepository.currentUserDto.asFlow()) { user, userDto ->
|
||||
|
|
@ -69,12 +78,52 @@ class NavDrawerService
|
|||
showToast(context, "Error fetching user's views")
|
||||
}.launchIn(coroutineScope)
|
||||
|
||||
// Handle when the user has logged into a Seerr server
|
||||
seerrServerRepository.active
|
||||
.onEach { discoverActive ->
|
||||
_state.update { it.copy(discoverEnabled = discoverActive) }
|
||||
}.launchIn(coroutineScope)
|
||||
|
||||
// Handle when music is actively playing or not
|
||||
coroutineScope.launchDefault {
|
||||
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(
|
||||
userId: UUID,
|
||||
tvAccess: Boolean,
|
||||
|
|
@ -108,6 +157,9 @@ class NavDrawerService
|
|||
return libraries
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the libraries that the user has not "pinned". These will show in the More section.
|
||||
*/
|
||||
suspend fun getFilteredUserLibraries(
|
||||
user: JellyfinUser,
|
||||
tvAccess: Boolean,
|
||||
|
|
@ -122,6 +174,9 @@ class NavDrawerService
|
|||
return libraries
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current state of the nav drawer items
|
||||
*/
|
||||
suspend fun updateNavDrawer(
|
||||
user: JellyfinUser,
|
||||
userDto: UserDto,
|
||||
|
|
@ -185,9 +240,11 @@ data class NavDrawerItemState(
|
|||
val items: List<NavDrawerItem>,
|
||||
val moreItems: List<NavDrawerItem>,
|
||||
val discoverEnabled: Boolean,
|
||||
val nowPlayingEnabled: Boolean,
|
||||
val nowPlayingTitle: String?,
|
||||
) {
|
||||
companion object {
|
||||
val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false)
|
||||
val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false, false, null)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
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 org.acra.ACRA
|
||||
import timber.log.Timber
|
||||
|
|
@ -14,7 +14,7 @@ import javax.inject.Singleton
|
|||
class NavigationManager
|
||||
@Inject
|
||||
constructor() {
|
||||
var backStack: MutableList<NavKey> = mutableListOf()
|
||||
var backStack: MutableList<Destination> = NavBackStack(Destination.Home())
|
||||
|
||||
/**
|
||||
* Go to the specified [com.github.damontecres.wholphin.ui.nav.Destination]
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ import org.jellyfin.sdk.api.client.extensions.itemsApi
|
|||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Gets people in media, specifically to check if they are favorited or not
|
||||
*/
|
||||
@Singleton
|
||||
class PeopleFavorites
|
||||
@Inject
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package com.github.damontecres.wholphin.services
|
|||
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import dagger.hilt.android.scopes.ActivityRetainedScoped
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
@ -20,13 +19,14 @@ class PlaybackLifecycleObserver
|
|||
private var wasPlaying: Boolean? = null
|
||||
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
val lastDest = navigationManager.backStack.lastOrNull()
|
||||
if (lastDest is Destination.Playback ||
|
||||
lastDest is Destination.PlaybackList ||
|
||||
lastDest is Destination.Slideshow
|
||||
) {
|
||||
navigationManager.goBack()
|
||||
}
|
||||
// TODO
|
||||
// val lastDest = navigationManager.backStack.lastOrNull()
|
||||
// if (lastDest is Destination.Playback ||
|
||||
// lastDest is Destination.PlaybackList ||
|
||||
// lastDest is Destination.Slideshow
|
||||
// ) {
|
||||
// navigationManager.goBack()
|
||||
// }
|
||||
wasPlaying = null
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,27 +6,39 @@ import android.content.Context
|
|||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
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.datasource.DefaultDataSource
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.Renderer
|
||||
import androidx.media3.exoplayer.RenderersFactory
|
||||
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.VideoRendererEventListener
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import androidx.media3.extractor.DefaultExtractorsFactory
|
||||
import com.github.damontecres.wholphin.preferences.AssPlaybackMode
|
||||
import com.github.damontecres.wholphin.preferences.MediaExtensionStatus
|
||||
import com.github.damontecres.wholphin.preferences.PlaybackPreferences
|
||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
||||
import com.github.damontecres.wholphin.util.mpv.MpvPlayer
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import io.github.peerless2012.ass.media.AssHandler
|
||||
import io.github.peerless2012.ass.media.factory.AssRenderersFactory
|
||||
import io.github.peerless2012.ass.media.kt.withAssMkvSupport
|
||||
import io.github.peerless2012.ass.media.parser.AssSubtitleParserFactory
|
||||
import io.github.peerless2012.ass.media.type.AssRenderType
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import timber.log.Timber
|
||||
import java.lang.reflect.Constructor
|
||||
import javax.inject.Inject
|
||||
|
|
@ -40,77 +52,23 @@ class PlayerFactory
|
|||
@Inject
|
||||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val appPreferences: DataStore<AppPreferences>,
|
||||
@param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient,
|
||||
) {
|
||||
@Volatile
|
||||
var currentPlayer: Player? = null
|
||||
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(
|
||||
backend: PlayerBackend,
|
||||
prefs: PlaybackPreferences,
|
||||
): Player {
|
||||
): PlayerCreation {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (currentPlayer?.isReleased == false) {
|
||||
Timber.w("Player was not released before trying to create a new one!")
|
||||
currentPlayer?.release()
|
||||
}
|
||||
}
|
||||
|
||||
var assHandler: AssHandler? = null
|
||||
val newPlayer =
|
||||
when (backend) {
|
||||
PlayerBackend.PREFER_MPV,
|
||||
|
|
@ -125,8 +83,14 @@ class PlayerFactory
|
|||
PlayerBackend.UNRECOGNIZED,
|
||||
-> {
|
||||
val extensions = prefs.overrides.mediaExtensionsEnabled
|
||||
val useLibAss =
|
||||
prefs.overrides.assPlaybackMode == AssPlaybackMode.ASS_LIBASS
|
||||
val decodeAv1 = prefs.overrides.decodeAv1
|
||||
Timber.v("extensions=$extensions")
|
||||
Timber.v(
|
||||
"extensions=%s, assPlaybackMode=%s",
|
||||
extensions,
|
||||
prefs.overrides.assPlaybackMode,
|
||||
)
|
||||
val rendererMode =
|
||||
when (extensions) {
|
||||
MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
|
||||
|
|
@ -134,17 +98,112 @@ class PlayerFactory
|
|||
MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF
|
||||
else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
|
||||
}
|
||||
ExoPlayer
|
||||
.Builder(context)
|
||||
.setRenderersFactory(
|
||||
val dataSourceFactory = DefaultDataSource.Factory(context)
|
||||
val extractorsFactory = createExtractorsFactory()
|
||||
var renderersFactory: RenderersFactory =
|
||||
WholphinRenderersFactory(context, decodeAv1)
|
||||
.setEnableDecoderFallback(true)
|
||||
.setExtensionRendererMode(rendererMode),
|
||||
).build()
|
||||
.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
|
||||
.Builder(context)
|
||||
.setMediaSourceFactory(mediaSourceFactory)
|
||||
.setRenderersFactory(renderersFactory)
|
||||
.setTrackSelector(trackSelector)
|
||||
.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
|
||||
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(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
class WholphinRenderersFactory(
|
||||
context: Context,
|
||||
private val av1Enabled: Boolean,
|
||||
) : DefaultRenderersFactory(context) {
|
||||
@OptIn(ExperimentalApi::class)
|
||||
override fun buildVideoRenderers(
|
||||
context: Context,
|
||||
extensionRendererMode: Int,
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@ import java.util.UUID
|
|||
import javax.inject.Inject
|
||||
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
|
||||
class PlaylistCreator
|
||||
@Inject
|
||||
|
|
@ -74,6 +79,9 @@ class PlaylistCreator
|
|||
return Playlist(episodes, startIndex)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from a server playlist ID
|
||||
*/
|
||||
suspend fun createFromPlaylistId(
|
||||
playlistId: UUID,
|
||||
startIndex: Int?,
|
||||
|
|
@ -114,7 +122,12 @@ class PlaylistCreator
|
|||
req =
|
||||
GetItemsRequest(
|
||||
parentId = item.id,
|
||||
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
|
||||
enableImageTypes =
|
||||
listOf(
|
||||
ImageType.PRIMARY,
|
||||
ImageType.THUMB,
|
||||
ImageType.LOGO,
|
||||
),
|
||||
includeItemTypes = includeItemTypes,
|
||||
recursive = true,
|
||||
excludeItemIds = listOf(item.id),
|
||||
|
|
@ -133,6 +146,11 @@ class PlaylistCreator
|
|||
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(
|
||||
item: BaseItemDto,
|
||||
startIndex: Int = 0,
|
||||
|
|
@ -270,6 +288,9 @@ class PlaylistCreator
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the playlists on the server for a given media type
|
||||
*/
|
||||
suspend fun getServerPlaylists(
|
||||
mediaType: MediaType?,
|
||||
scope: CoroutineScope,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ import javax.inject.Singleton
|
|||
import kotlin.math.roundToInt
|
||||
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
|
||||
class RefreshRateService
|
||||
@Inject
|
||||
|
|
@ -119,6 +122,9 @@ class RefreshRateService
|
|||
MainActivity.instance.changeDisplayMode(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Listens for the display to change so we known the refresh rate or resolution is updated
|
||||
*/
|
||||
private class Listener(
|
||||
val displayId: Int,
|
||||
) : DisplayManager.DisplayListener {
|
||||
|
|
@ -213,6 +219,9 @@ class RefreshRateService
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for a [Display.Mode]
|
||||
*/
|
||||
data class DisplayMode(
|
||||
val modeId: Int,
|
||||
val physicalWidth: Int,
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ import javax.inject.Inject
|
|||
import javax.inject.Singleton
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Handles the queue of items to show on the screensaver, both in-app or OS
|
||||
*/
|
||||
@Singleton
|
||||
class ScreensaverService
|
||||
@Inject
|
||||
|
|
@ -67,6 +70,9 @@ class ScreensaverService
|
|||
}.launchIn(scope)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the timer before showing the in-app screensaver
|
||||
*/
|
||||
fun pulse() {
|
||||
waitJob?.cancel()
|
||||
if (_state.value.enabled) {
|
||||
|
|
@ -95,6 +101,9 @@ class ScreensaverService
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediately start the in-app screensaver
|
||||
*/
|
||||
fun start() {
|
||||
_state.update {
|
||||
it.copy(
|
||||
|
|
@ -104,6 +113,9 @@ class ScreensaverService
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediately stop the in-app screensaver
|
||||
*/
|
||||
fun stop(cancelJob: Boolean) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
|
|
@ -114,6 +126,9 @@ class ScreensaverService
|
|||
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) {
|
||||
scope.launchDefault {
|
||||
val screensaverEnabled = _state.value.enabled
|
||||
|
|
@ -136,6 +151,9 @@ class ScreensaverService
|
|||
keepScreenOn.update { keep }
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a flow of items to show on the screensaver
|
||||
*/
|
||||
fun createItemFlow(scope: CoroutineScope): Flow<CurrentItem?> =
|
||||
flow {
|
||||
val prefs =
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.setup.seerr.createSeerrApiUrl
|
||||
|
|
@ -19,7 +20,7 @@ class SeerrApi(
|
|||
)
|
||||
private set
|
||||
|
||||
val active: Boolean get() = api.baseUrl.isNotNullOrBlank()
|
||||
val active: Boolean get() = api.baseUrl.isNotNullOrBlank() && BuildConfig.DISCOVER_ENABLED
|
||||
|
||||
fun update(
|
||||
baseUrl: String,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import android.content.Context
|
|||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.asFlow
|
||||
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.model.AuthJellyfinPostRequest
|
||||
import com.github.damontecres.wholphin.api.seerr.model.AuthLocalPostRequest
|
||||
|
|
@ -292,6 +293,7 @@ class UserSwitchListener
|
|||
launchIO {
|
||||
homeSettingsService.loadCurrentSettings(user.id)
|
||||
}
|
||||
if (BuildConfig.DISCOVER_ENABLED) {
|
||||
// Check for seerr server
|
||||
launchIO {
|
||||
seerrServerDao
|
||||
|
|
@ -333,6 +335,7 @@ class UserSwitchListener
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun CurrentSeerr?.imageUrlBuilder(
|
||||
imageType: ImageType,
|
||||
|
|
|
|||
|
|
@ -191,6 +191,18 @@ class SeerrService
|
|||
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 =
|
||||
DiscoverItem(
|
||||
id = movie.id,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ import org.jellyfin.sdk.model.api.MediaType
|
|||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Listens for basic messages from the server such as messages
|
||||
*/
|
||||
@ActivityScoped
|
||||
class ServerEventListener
|
||||
@Inject
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@ import javax.inject.Singleton
|
|||
@Singleton
|
||||
class SetupNavigationManager
|
||||
@Inject
|
||||
constructor() {
|
||||
constructor(
|
||||
private val navigationManager: NavigationManager,
|
||||
) {
|
||||
var backStack: MutableList<SetupDestination> = mutableStateListOf(SetupDestination.Loading)
|
||||
|
||||
/**
|
||||
|
|
@ -25,6 +27,9 @@ class SetupNavigationManager
|
|||
fun navigateTo(destination: SetupDestination) {
|
||||
backStack[0] = destination
|
||||
log()
|
||||
if (destination !is SetupDestination.AppContent) {
|
||||
navigationManager.reloadHome()
|
||||
}
|
||||
}
|
||||
|
||||
private fun log() {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ import java.util.UUID
|
|||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Manage the track choices for media
|
||||
*/
|
||||
@Singleton
|
||||
class StreamChoiceService
|
||||
@Inject
|
||||
|
|
@ -94,6 +97,9 @@ class StreamChoiceService
|
|||
result
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the audio stream that should play
|
||||
*/
|
||||
suspend fun chooseAudioStream(
|
||||
source: MediaSourceInfo,
|
||||
seriesId: UUID?,
|
||||
|
|
@ -108,6 +114,9 @@ class StreamChoiceService
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the audio stream that should play
|
||||
*/
|
||||
fun chooseAudioStream(
|
||||
candidates: List<MediaStream>,
|
||||
itemPlayback: ItemPlayback?,
|
||||
|
|
@ -135,6 +144,9 @@ class StreamChoiceService
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the subtitle stream that should play
|
||||
*/
|
||||
suspend fun chooseSubtitleStream(
|
||||
source: MediaSourceInfo,
|
||||
audioStream: MediaStream?,
|
||||
|
|
@ -196,6 +208,9 @@ class StreamChoiceService
|
|||
)?.index
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the subtitle stream that should play
|
||||
*/
|
||||
fun chooseSubtitleStream(
|
||||
audioStreamLang: String?,
|
||||
candidates: List<MediaStream>,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
|||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Gets trailers for media
|
||||
*/
|
||||
@Singleton
|
||||
class TrailerService
|
||||
@Inject
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@ import javax.inject.Singleton
|
|||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Checks if an app update is available
|
||||
*/
|
||||
@Singleton
|
||||
class UpdateChecker
|
||||
@Inject
|
||||
|
|
@ -62,9 +65,14 @@ class UpdateChecker
|
|||
|
||||
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(
|
||||
updateUrl: String,
|
||||
showNegativeToast: Boolean = false,
|
||||
|
|
@ -112,20 +120,44 @@ class UpdateChecker
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently installed version
|
||||
*/
|
||||
fun getInstalledVersion(): Version {
|
||||
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) {
|
||||
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 =
|
||||
Request
|
||||
.Builder()
|
||||
.url(updateUrl)
|
||||
.get()
|
||||
.build()
|
||||
okHttpClient.newCall(request).execute().use {
|
||||
getRelease(request)
|
||||
}
|
||||
|
||||
private fun getRelease(request: Request): Release? {
|
||||
return okHttpClient.newCall(request).execute().use {
|
||||
if (it.isSuccessful && it.body != null) {
|
||||
val result = Json.parseToJsonElement(it.body!!.string())
|
||||
val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull
|
||||
|
|
@ -147,20 +179,22 @@ class UpdateChecker
|
|||
m.groupValues[1]
|
||||
}.toList()
|
||||
} else {
|
||||
listOf()
|
||||
emptyList()
|
||||
}
|
||||
return@use Release(version, downloadUrl, publishedAt, body, notes)
|
||||
} else {
|
||||
Timber.w("Update version parsing failed. name=$name")
|
||||
}
|
||||
} else {
|
||||
Timber.w("Update check failed: ${it.message}")
|
||||
Timber.w("Update check failed ${it.code}: ${it.message}")
|
||||
}
|
||||
return@use null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and install an update
|
||||
*/
|
||||
suspend fun installRelease(
|
||||
release: Release,
|
||||
callback: DownloadCallback,
|
||||
|
|
@ -263,6 +297,9 @@ class UpdateChecker
|
|||
return targetFile
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the app has permission to write the download
|
||||
*/
|
||||
fun hasPermissions(): Boolean =
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
|
||||
(
|
||||
|
|
@ -327,7 +364,19 @@ data class Release(
|
|||
val publishedAt: String?,
|
||||
val body: 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 {
|
||||
fun contentLength(contentLength: Long)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ import kotlinx.coroutines.flow.map
|
|||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Get the current user's [UserPreferences]
|
||||
*/
|
||||
@Singleton
|
||||
class UserPreferencesService
|
||||
@Inject
|
||||
|
|
|
|||
|
|
@ -34,26 +34,48 @@ import org.jellyfin.sdk.model.DeviceInfo
|
|||
import javax.inject.Qualifier
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* An [OkHttpClient] that includes the user's access token when making requests
|
||||
*/
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class AuthOkHttpClient
|
||||
|
||||
/**
|
||||
* A basic [OkHttpClient] that does not include auth
|
||||
*/
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class StandardOkHttpClient
|
||||
|
||||
/**
|
||||
* A [CoroutineScope] with [Dispatchers.IO]
|
||||
*/
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class IoCoroutineScope
|
||||
|
||||
/**
|
||||
* A [CoroutineScope] with [Dispatchers.Default]
|
||||
*/
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class DefaultCoroutineScope
|
||||
|
||||
/**
|
||||
* [Dispatchers.IO]
|
||||
*
|
||||
* @see IoCoroutineScope
|
||||
*/
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class IoDispatcher
|
||||
|
||||
/**
|
||||
* [Dispatchers.Default]
|
||||
*
|
||||
* @see DefaultCoroutineScope
|
||||
*/
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class DefaultDispatcher
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ import androidx.datastore.core.DataStore
|
|||
import androidx.datastore.core.DataStoreFactory
|
||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||
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 com.github.damontecres.wholphin.data.AppDatabase
|
||||
import com.github.damontecres.wholphin.data.ItemPlaybackDao
|
||||
|
|
@ -85,4 +88,13 @@ object DatabaseModule {
|
|||
produceNewData = { AppPreferences.getDefaultInstance() },
|
||||
),
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun keyValueDataStore(
|
||||
@ApplicationContext context: Context,
|
||||
): DataStore<Preferences> =
|
||||
PreferenceDataStoreFactory.create(
|
||||
produceFile = { context.preferencesDataStoreFile("key_value") },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ import kotlin.time.Duration.Companion.minutes
|
|||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.toJavaDuration
|
||||
|
||||
/**
|
||||
* Schedules the [TvProviderWorker] to update the OS resume watching row
|
||||
*/
|
||||
@ActivityScoped
|
||||
class TvProviderSchedulerService
|
||||
@Inject
|
||||
|
|
@ -44,6 +47,7 @@ class TvProviderSchedulerService
|
|||
workManager.cancelUniqueWork(TvProviderWorker.WORK_NAME)
|
||||
if (supportsTvProvider) {
|
||||
if (user != null) {
|
||||
// Schedule a new worker whenever the user changes
|
||||
activity.lifecycleScope.launchIO(ExceptionHandler()) {
|
||||
Timber.i("Scheduling TvProviderWorker for ${user.user}")
|
||||
workManager
|
||||
|
|
@ -70,6 +74,9 @@ class TvProviderSchedulerService
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the [TvProviderWorker] as a one-off instead of scheduled
|
||||
*/
|
||||
fun launchOneTimeRefresh() {
|
||||
if (supportsTvProvider) {
|
||||
activity.lifecycleScope.launchIO(ExceptionHandler()) {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,11 @@ import java.util.Date
|
|||
import java.util.UUID
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
/**
|
||||
* Updates the Android OS resume watching row for a given Jellyfin user
|
||||
*
|
||||
* @see TvProviderSchedulerService
|
||||
*/
|
||||
@HiltWorker
|
||||
class TvProviderWorker
|
||||
@AssistedInject
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ class CoilTrickplayTransformation(
|
|||
val index: Int,
|
||||
) : Transformation() {
|
||||
private val x: Int = imageIndex % numColumns
|
||||
private val y: Int = imageIndex / numRows
|
||||
private val y: Int = imageIndex / numColumns
|
||||
|
||||
override val cacheKey: String
|
||||
get() = "CoilTrickplayTransformation_$index,$x,$y"
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ import kotlinx.coroutines.CoroutineScope
|
|||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.acra.ACRA
|
||||
|
|
@ -434,3 +437,18 @@ fun Response<BaseItemDtoQueryResult>.toBaseItems(
|
|||
fun Int?.gt(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) }
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import androidx.compose.ui.text.buildAnnotatedString
|
|||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.WholphinApplication
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.MediaSegmentType
|
||||
import timber.log.Timber
|
||||
import java.time.LocalDate
|
||||
|
|
@ -16,8 +17,25 @@ import java.time.format.DateTimeParseException
|
|||
import java.time.format.FormatStyle
|
||||
import java.util.Locale
|
||||
|
||||
val TimeFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
||||
val DateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("MMM d, yyyy")
|
||||
private var timeFormatter: DateTimeFormatter =
|
||||
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
|
||||
// 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`
|
||||
*/
|
||||
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? =
|
||||
date?.let {
|
||||
|
|
@ -109,30 +129,25 @@ fun abbreviateNumber(number: Int): String {
|
|||
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")
|
||||
|
||||
/**
|
||||
* Format bytes
|
||||
*/
|
||||
fun formatBytes(
|
||||
bytes: Int,
|
||||
suffixes: List<String> = byteSuffixes,
|
||||
) = formatBytes(bytes.toLong(), suffixes)
|
||||
|
||||
fun formatBytes(
|
||||
bytes: Long,
|
||||
suffixes: List<String> = byteSuffixes,
|
||||
divisor: Int = 1024,
|
||||
): String {
|
||||
var unit = 0
|
||||
var count = bytes.toDouble()
|
||||
while (count >= 1024 && unit + 1 < suffixes.size) {
|
||||
count /= 1024
|
||||
while (count >= divisor && unit + 1 < suffixes.size) {
|
||||
count /= divisor
|
||||
unit++
|
||||
}
|
||||
return String.format(Locale.getDefault(), "%.2f %s", count, suffixes[unit])
|
||||
}
|
||||
|
||||
fun formatBitrate(bitrate: Int) = formatBytes(bitrate.toLong(), byteRateSuffixes, 1000)
|
||||
|
||||
@get:StringRes
|
||||
val MediaSegmentType.stringRes: Int
|
||||
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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -98,10 +99,15 @@ fun BannerCard(
|
|||
}
|
||||
}
|
||||
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(
|
||||
modifier = modifier.size(cardHeight * aspectRatio, cardHeight),
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
onClick = { currentOnClick() },
|
||||
onLongClick = { currentOnLongClick() },
|
||||
interactionSource = interactionSource,
|
||||
colors =
|
||||
CardDefaults.colors(
|
||||
|
|
@ -119,7 +125,7 @@ fun BannerCard(
|
|||
model = imageUrl,
|
||||
contentDescription = null,
|
||||
contentScale = imageContentScale,
|
||||
onError = { imageError = true },
|
||||
onError = remember { { imageError = true } },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else {
|
||||
|
|
@ -214,7 +220,7 @@ fun BannerCardWithTitle(
|
|||
) {
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
|
||||
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
|
||||
val spaceBelow by animateDpAsState(if (focused) 0.dp else 8.dp)
|
||||
val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
|
||||
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
|
||||
val width = cardHeight * aspectRationToUse
|
||||
|
|
|
|||
|
|
@ -14,9 +14,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowForward
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.NonRestartableComposable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -34,8 +35,10 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Card
|
||||
import androidx.tv.material3.CardDefaults
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import androidx.tv.material3.surfaceColorAtElevation
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||
import com.github.damontecres.wholphin.data.model.SeerrAvailability
|
||||
|
|
@ -50,14 +53,14 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
|||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
@NonRestartableComposable
|
||||
fun DiscoverItemCard(
|
||||
item: DiscoverItem?,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
showOverlay: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
showOverlay: Boolean = true,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
width: Dp = Cards.height2x3 * AspectRatios.TALL,
|
||||
) {
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
|
||||
|
|
@ -77,16 +80,15 @@ fun DiscoverItemCard(
|
|||
} else {
|
||||
focusedAfterDelay = false
|
||||
}
|
||||
val width = Cards.height2x3 * AspectRatios.TALL
|
||||
val height = Dp.Unspecified * (1f / AspectRatios.TALL)
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(spaceBetween),
|
||||
modifier = modifier.size(width, height),
|
||||
modifier = modifier.size(width, Dp.Unspecified),
|
||||
) {
|
||||
Card(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(Dp.Unspecified, Cards.height2x3)
|
||||
.size(width, Dp.Unspecified)
|
||||
.aspectRatio(AspectRatios.TALL),
|
||||
onClick = onClick,
|
||||
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
|
||||
@Composable
|
||||
private fun Preview() {
|
||||
|
|
@ -294,6 +378,11 @@ private fun Preview() {
|
|||
PendingIndicator()
|
||||
AvailableIndicator()
|
||||
PartiallyAvailableIndicator()
|
||||
DiscoverViewMoreCard(
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ fun EpisodeCard(
|
|||
modifier: Modifier = Modifier,
|
||||
imageHeight: Dp = Dp.Unspecified,
|
||||
imageWidth: Dp = Dp.Unspecified,
|
||||
showImageOverlay: Boolean = false,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
val dto = item?.data
|
||||
|
|
@ -94,7 +95,7 @@ fun EpisodeCard(
|
|||
ItemCardImage(
|
||||
item = item,
|
||||
name = item?.name,
|
||||
showOverlay = false,
|
||||
showOverlay = showImageOverlay,
|
||||
favorite = dto?.userData?.isFavorite ?: false,
|
||||
watched = dto?.userData?.played ?: false,
|
||||
unwatchedCount = dto?.userData?.unplayedItemCount ?: -1,
|
||||
|
|
|
|||
|
|
@ -10,8 +10,10 @@ import androidx.compose.foundation.lazy.LazyRow
|
|||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.NonRestartableComposable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
|
|
@ -45,6 +47,10 @@ fun <T> ItemRow(
|
|||
val firstFocus = remember { FocusRequester() }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
var position by rememberInt()
|
||||
|
||||
val currentOnClickItem by rememberUpdatedState(onClickItem)
|
||||
val currentOnLongClickItem by rememberUpdatedState(onLongClickItem)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier =
|
||||
|
|
@ -54,12 +60,8 @@ fun <T> ItemRow(
|
|||
}
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
ItemRowTitle(title)
|
||||
|
||||
LazyRow(
|
||||
state = state,
|
||||
horizontalArrangement = Arrangement.spacedBy(horizontalPadding),
|
||||
|
|
@ -73,25 +75,50 @@ fun <T> ItemRow(
|
|||
) {
|
||||
itemsIndexed(items) { index, item ->
|
||||
val cardModifier =
|
||||
remember(index, position) {
|
||||
if (index == position) {
|
||||
Modifier.focusRequester(firstFocus)
|
||||
} 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(
|
||||
index,
|
||||
item,
|
||||
cardModifier,
|
||||
{
|
||||
position = index
|
||||
if (item != null) onClickItem.invoke(index, item)
|
||||
},
|
||||
{
|
||||
position = index
|
||||
if (item != null) onLongClickItem.invoke(index, item)
|
||||
},
|
||||
onClick,
|
||||
onLongClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Column
|
|||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
|
|
@ -25,9 +26,11 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Border
|
||||
|
|
@ -80,7 +83,7 @@ fun PersonCard(
|
|||
) {
|
||||
val hideOverlayDelay = 1_000L
|
||||
|
||||
val focused = interactionSource.collectIsFocusedAsState().value
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
var focusedAfterDelay by remember { mutableStateOf(false) }
|
||||
|
||||
if (focused) {
|
||||
|
|
@ -95,10 +98,13 @@ fun PersonCard(
|
|||
} else {
|
||||
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(
|
||||
verticalArrangement = Arrangement.spacedBy(spaceBetween),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp), // Fixed base spacing
|
||||
modifier = modifier,
|
||||
) {
|
||||
Card(
|
||||
|
|
@ -168,8 +174,16 @@ fun PersonCard(
|
|||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(bottom = spaceBelow)
|
||||
.fillMaxWidth(),
|
||||
// Optimization: move animation reads to layout/draw phase
|
||||
.offset {
|
||||
IntOffset(0, (spaceBetweenState.value - 4.dp).roundToPx())
|
||||
}.layout { measurable, constraints ->
|
||||
val paddingPx = spaceBelowState.value.roundToPx()
|
||||
val placeable = measurable.measure(constraints)
|
||||
layout(placeable.width, placeable.height + paddingPx) {
|
||||
placeable.placeRelative(0, 0)
|
||||
}
|
||||
}.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = name ?: "",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Column
|
|||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -18,10 +19,12 @@ import androidx.compose.runtime.mutableStateOf
|
|||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.Card
|
||||
import androidx.tv.material3.CardDefaults
|
||||
|
|
@ -52,7 +55,7 @@ fun SeasonCard(
|
|||
val imageUrlService = LocalImageUrlService.current
|
||||
val density = LocalDensity.current
|
||||
val imageUrl =
|
||||
remember(item, imageHeight, imageWidth) {
|
||||
remember(item, imageHeight, imageWidth, density) {
|
||||
if (item != null) {
|
||||
val fillHeight =
|
||||
if (imageHeight != Dp.Unspecified) {
|
||||
|
|
@ -125,14 +128,17 @@ fun SeasonCard(
|
|||
aspectRatio: Float = AspectRatios.TALL,
|
||||
) {
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
|
||||
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
|
||||
// Do not use `by` here, this way we are Defer reads and recompositions to only when modifier calculates
|
||||
val spaceBetween = animateDpAsState(if (focused) 12.dp else 4.dp, label = "spaceBetween")
|
||||
val spaceBelow = animateDpAsState(if (focused) 4.dp else 12.dp, label = "spaceBelow")
|
||||
|
||||
val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
|
||||
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
|
||||
val width = imageHeight * aspectRationToUse
|
||||
val height = imageWidth * (1f / aspectRationToUse)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(spaceBetween),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp), // Fixed base spacing
|
||||
modifier = modifier.size(width, height),
|
||||
) {
|
||||
Card(
|
||||
|
|
@ -173,8 +179,16 @@ fun SeasonCard(
|
|||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(bottom = spaceBelow)
|
||||
.fillMaxWidth(),
|
||||
// Optimization: move animation reads to layout/draw phase
|
||||
.offset {
|
||||
IntOffset(0, (spaceBetween.value - 4.dp).roundToPx())
|
||||
}.layout { measurable, constraints ->
|
||||
val paddingPx = spaceBelow.value.roundToPx()
|
||||
val placeable = measurable.measure(constraints)
|
||||
layout(placeable.width, placeable.height + paddingPx) {
|
||||
placeable.placeRelative(0, 0)
|
||||
}
|
||||
}.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = title ?: "",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -35,6 +35,11 @@ import androidx.tv.material3.ProvideTextStyle
|
|||
import androidx.tv.material3.Surface
|
||||
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
|
||||
fun Button(
|
||||
onClick: () -> Unit,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import androidx.compose.material.icons.Icons
|
|||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.NonRestartableComposable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
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.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.ThemeSongPlayer
|
||||
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.PlaylistLoadingState
|
||||
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.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
|
|
@ -138,6 +141,7 @@ class CollectionFolderViewModel
|
|||
private val themeSongPlayer: ThemeSongPlayer,
|
||||
private val userPreferencesService: UserPreferencesService,
|
||||
private val mediaManagementService: MediaManagementService,
|
||||
private val musicService: MusicService,
|
||||
val mediaReportService: MediaReportService,
|
||||
@Assisted itemId: String,
|
||||
@Assisted initialSortAndDirection: SortAndDirection?,
|
||||
|
|
@ -398,6 +402,7 @@ class CollectionFolderViewModel
|
|||
ImageType.PRIMARY,
|
||||
ImageType.THUMB,
|
||||
ImageType.BACKDROP,
|
||||
ImageType.LOGO,
|
||||
),
|
||||
includeItemTypes = includeItemTypes,
|
||||
recursive = recursive,
|
||||
|
|
@ -530,6 +535,11 @@ class CollectionFolderViewModel
|
|||
item: BaseItem,
|
||||
appPreferences: AppPreferences,
|
||||
): Boolean = mediaManagementService.canDelete(item, appPreferences)
|
||||
|
||||
fun addToQueue(
|
||||
item: BaseItem,
|
||||
index: Int,
|
||||
) = addToQueue(api, musicService, item, index)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -774,6 +784,9 @@ fun CollectionFolderGrid(
|
|||
onClickGoTo = {
|
||||
onClickItem.invoke(position, it)
|
||||
},
|
||||
onClickAddToQueue = {
|
||||
viewModel.addToQueue(it, 0)
|
||||
},
|
||||
),
|
||||
),
|
||||
onDismissRequest = { moreDialog.makeAbsent() },
|
||||
|
|
@ -881,15 +894,7 @@ fun CollectionFolderGridContent(
|
|||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (showTitle) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
GridTitle(title)
|
||||
}
|
||||
val endPadding =
|
||||
16.dp + if (sortAndDirection.sort == ItemSortBy.SORT_NAME) 24.dp else 0.dp
|
||||
|
|
@ -960,11 +965,12 @@ fun CollectionFolderGridContent(
|
|||
AnimatedVisibility(viewOptions.showDetails) {
|
||||
HomePageHeader(
|
||||
item = focusedItem,
|
||||
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp)
|
||||
.padding(top = 48.dp, bottom = 32.dp, start = 8.dp),
|
||||
.padding(HeaderUtils.padding),
|
||||
)
|
||||
}
|
||||
when (val state = loadingState) {
|
||||
|
|
@ -1144,3 +1150,18 @@ val CollectionType.baseItemKinds: List<BaseItemKind>
|
|||
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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -76,6 +76,8 @@ import kotlinx.coroutines.launch
|
|||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.MediaStream
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Parameters for rendering a [DialogPopup]
|
||||
|
|
@ -90,6 +92,9 @@ sealed interface DialogItemEntry
|
|||
|
||||
data object DialogItemDivider : DialogItemEntry
|
||||
|
||||
/**
|
||||
* An item within a [DialogPopup]
|
||||
*/
|
||||
data class DialogItem(
|
||||
val headlineContent: @Composable () -> Unit,
|
||||
val onClick: () -> Unit,
|
||||
|
|
@ -594,6 +599,7 @@ fun ConfirmDeleteDialog(
|
|||
fun chooseVersionParams(
|
||||
context: Context,
|
||||
sources: List<MediaSourceInfo>,
|
||||
chosenSourceId: UUID?,
|
||||
onClick: (Int) -> Unit,
|
||||
): DialogParams =
|
||||
DialogParams(
|
||||
|
|
@ -601,6 +607,7 @@ fun chooseVersionParams(
|
|||
title = context.getString(R.string.choose_stream, context.getString(R.string.version)),
|
||||
items =
|
||||
sources.filter { it.id.isNotNullOrBlank() }.mapIndexed { index, source ->
|
||||
val uuid = source.id?.toUUIDOrNull()
|
||||
val videoStream =
|
||||
source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }
|
||||
val title = source.name ?: source.path ?: source.id ?: ""
|
||||
|
|
@ -608,6 +615,9 @@ fun chooseVersionParams(
|
|||
headlineContent = {
|
||||
Text(text = title)
|
||||
},
|
||||
leadingContent = {
|
||||
SelectedLeadingContent(uuid != null && uuid == chosenSourceId)
|
||||
},
|
||||
supportingContent = {
|
||||
videoStream?.displayTitle?.let { Text(text = it) }
|
||||
},
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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.ui.PreviewTvSpec
|
||||
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)
|
||||
@Composable
|
||||
fun EditTextBox(
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import androidx.tv.material3.MaterialTheme
|
|||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
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 dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
|
|
@ -98,3 +99,13 @@ fun ErrorMessage(
|
|||
exception = error.exception,
|
||||
modifier = modifier,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ErrorMessage(
|
||||
error: DataLoadingState.Error,
|
||||
modifier: Modifier = Modifier,
|
||||
) = ErrorMessage(
|
||||
message = error.message,
|
||||
exception = error.exception,
|
||||
modifier = modifier,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.OfficialRatingFilter
|
||||
import com.github.damontecres.wholphin.data.filter.PlayedFilter
|
||||
import com.github.damontecres.wholphin.data.filter.StudioFilter
|
||||
import com.github.damontecres.wholphin.data.filter.VideoTypeFilter
|
||||
import com.github.damontecres.wholphin.data.filter.YearFilter
|
||||
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||
|
|
@ -55,6 +56,14 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
|||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
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
|
||||
fun FilterByButton(
|
||||
filterOptions: List<ItemFilterBy<*>>,
|
||||
|
|
@ -198,7 +207,9 @@ fun FilterByButton(
|
|||
val isSelected =
|
||||
remember(currentValue) {
|
||||
when (filterOption) {
|
||||
GenreFilter -> {
|
||||
GenreFilter,
|
||||
StudioFilter,
|
||||
-> {
|
||||
(currentValue as? List<UUID>)
|
||||
.orEmpty()
|
||||
.contains(value.value)
|
||||
|
|
@ -263,7 +274,9 @@ fun FilterByButton(
|
|||
onClick = {
|
||||
val newFilter =
|
||||
when (filterOption) {
|
||||
GenreFilter -> {
|
||||
GenreFilter,
|
||||
StudioFilter,
|
||||
-> {
|
||||
val list = (currentValue as? List<UUID>).orEmpty()
|
||||
val newValue =
|
||||
list
|
||||
|
|
|
|||
|
|
@ -139,6 +139,7 @@ class GenreViewModel
|
|||
nameLessThan = letter.toString(),
|
||||
limit = 0,
|
||||
enableTotalRecordCount = true,
|
||||
includeItemTypes = includeItemTypes,
|
||||
)
|
||||
val result by GetGenresRequestHandler.execute(api, request)
|
||||
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(
|
||||
api: ApiClient,
|
||||
userId: UUID?,
|
||||
|
|
@ -230,6 +234,9 @@ data class Genre(
|
|||
override val sortName: String get() = name
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an optimized grid of genres for a library
|
||||
*/
|
||||
@Composable
|
||||
fun GenreCardGrid(
|
||||
itemId: UUID,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@ import androidx.tv.material3.Text
|
|||
|
||||
private const val MAX_TO_SHOW = 4
|
||||
|
||||
/**
|
||||
* Display a comma separated list of genres
|
||||
*
|
||||
* Only the first few will be shown
|
||||
*/
|
||||
@Composable
|
||||
fun GenreText(
|
||||
genres: List<String>,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
fun ItemGrid(
|
||||
destination: Destination.ItemGrid,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ import androidx.compose.ui.Modifier
|
|||
import com.mikepenz.aboutlibraries.ui.compose.android.produceLibraries
|
||||
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
|
||||
|
||||
/**
|
||||
* Displays dependencies' license information to comply with attribution
|
||||
*/
|
||||
@Composable
|
||||
fun LicenseInfo(modifier: Modifier = Modifier) {
|
||||
val libraries by produceLibraries()
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ import androidx.tv.material3.Text
|
|||
import com.github.damontecres.wholphin.ui.playOnClickSound
|
||||
import com.github.damontecres.wholphin.ui.playSoundOnFocus
|
||||
|
||||
/**
|
||||
* Show the overview text for an item. Uses a fixed size and allows for clicking.
|
||||
*/
|
||||
@Composable
|
||||
fun OverviewText(
|
||||
overview: String,
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ import androidx.tv.material3.Icon
|
|||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.ui.TimeFormatter
|
||||
import com.github.damontecres.wholphin.ui.dot
|
||||
import com.github.damontecres.wholphin.ui.getTimeFormatter
|
||||
import com.github.damontecres.wholphin.ui.util.LocalClock
|
||||
import kotlin.time.Duration
|
||||
|
||||
|
|
@ -107,7 +107,7 @@ fun TimeRemaining(
|
|||
val now by LocalClock.current.now
|
||||
val remainingStr =
|
||||
remember(remaining, now) {
|
||||
val endTimeStr = TimeFormatter.format(now.plusSeconds(remaining.inWholeSeconds))
|
||||
val endTimeStr = getTimeFormatter().format(now.plusSeconds(remaining.inWholeSeconds))
|
||||
buildAnnotatedString {
|
||||
dot()
|
||||
append(context.getString(R.string.ends_at, endTimeStr))
|
||||
|
|
|
|||
|
|
@ -19,12 +19,14 @@ import androidx.lifecycle.ViewModel
|
|||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
||||
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.deleteItem
|
||||
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.PlaylistLoadingState
|
||||
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.launchIO
|
||||
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.HomeRowLoadingState
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Abstract [ViewModel] for the "Recommended" tab for a library
|
||||
*/
|
||||
abstract class RecommendedViewModel(
|
||||
val context: Context,
|
||||
@param:ApplicationContext val context: Context,
|
||||
val api: ApiClient,
|
||||
val navigationManager: NavigationManager,
|
||||
val favoriteWatchManager: FavoriteWatchManager,
|
||||
val mediaReportService: MediaReportService,
|
||||
private val musicService: MusicService,
|
||||
private val backdropService: BackdropService,
|
||||
private val mediaManagementService: MediaManagementService,
|
||||
) : ViewModel() {
|
||||
|
|
@ -110,13 +120,14 @@ abstract class RecommendedViewModel(
|
|||
|
||||
fun update(
|
||||
@StringRes title: Int,
|
||||
viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
|
||||
block: suspend () -> List<BaseItem>,
|
||||
): Deferred<HomeRowLoadingState> =
|
||||
viewModelScope.async(Dispatchers.IO) {
|
||||
val titleStr = context.getString(title)
|
||||
val row =
|
||||
try {
|
||||
HomeRowLoadingState.Success(titleStr, block.invoke())
|
||||
HomeRowLoadingState.Success(titleStr, block.invoke(), viewOptions)
|
||||
} catch (ex: Exception) {
|
||||
HomeRowLoadingState.Error(titleStr, null, ex)
|
||||
}
|
||||
|
|
@ -141,6 +152,11 @@ abstract class RecommendedViewModel(
|
|||
item: BaseItem,
|
||||
appPreferences: AppPreferences,
|
||||
): Boolean = mediaManagementService.canDelete(item, appPreferences)
|
||||
|
||||
fun addToQueue(
|
||||
item: BaseItem,
|
||||
index: Int,
|
||||
) = addToQueue(api, musicService, item, index)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -205,6 +221,7 @@ fun RecommendedContent(
|
|||
},
|
||||
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
||||
onUpdateBackdrop = viewModel::updateBackdrop,
|
||||
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
|
@ -237,6 +254,9 @@ fun RecommendedContent(
|
|||
},
|
||||
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
||||
onClickDelete = { showDeleteDialog = RowColumnItem(position, item) },
|
||||
onClickAddToQueue = {
|
||||
viewModel.addToQueue(it, 0)
|
||||
},
|
||||
),
|
||||
),
|
||||
onDismissRequest = { moreDialog.makeAbsent() },
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ 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
|
||||
|
|
@ -55,7 +56,8 @@ class RecommendedMovieViewModel
|
|||
@AssistedInject
|
||||
constructor(
|
||||
@ApplicationContext context: Context,
|
||||
private val api: ApiClient,
|
||||
api: ApiClient,
|
||||
musicService: MusicService,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val preferencesDataStore: DataStore<AppPreferences>,
|
||||
private val suggestionService: SuggestionService,
|
||||
|
|
@ -67,9 +69,11 @@ class RecommendedMovieViewModel
|
|||
mediaManagementService: MediaManagementService,
|
||||
) : RecommendedViewModel(
|
||||
context,
|
||||
api,
|
||||
navigationManager,
|
||||
favoriteWatchManager,
|
||||
mediaReportService,
|
||||
musicService,
|
||||
backdropService,
|
||||
mediaManagementService,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue