mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +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 "BUILD_TOOLS_VERSION=36.0.0" >> $GITHUB_ENV
|
||||||
echo "NDK_VERSION=29.0.14206865" >> $GITHUB_ENV
|
echo "NDK_VERSION=29.0.14206865" >> $GITHUB_ENV
|
||||||
- name: Setup Android SDK
|
- name: Setup Android SDK
|
||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@v4
|
||||||
with:
|
with:
|
||||||
packages: "tools platform-tools build-tools;${{ env.BUILD_TOOLS_VERSION }} ndk;${{ env.NDK_VERSION }}"
|
packages: "tools platform-tools build-tools;${{ env.BUILD_TOOLS_VERSION }} ndk;${{ env.NDK_VERSION }}"
|
||||||
- name: Add NDK to path
|
- name: Add NDK to path
|
||||||
|
|
|
||||||
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
|
fetch-depth: 0 # Need the tags to build
|
||||||
- name: Setup
|
- name: Setup
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
- name: Native build
|
|
||||||
uses: ./.github/actions/native-build
|
|
||||||
with:
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Get version names
|
- name: Get version names
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -55,8 +51,11 @@ jobs:
|
||||||
KEY_PASSWORD: "${{ secrets.KEY_PASSWORD }}"
|
KEY_PASSWORD: "${{ secrets.KEY_PASSWORD }}"
|
||||||
KEY_STORE_PASSWORD: "${{ secrets.KEY_STORE_PASSWORD }}"
|
KEY_STORE_PASSWORD: "${{ secrets.KEY_STORE_PASSWORD }}"
|
||||||
SIGNING_KEY: "${{ secrets.SIGNING_KEY }}"
|
SIGNING_KEY: "${{ secrets.SIGNING_KEY }}"
|
||||||
|
ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}"
|
||||||
|
ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}"
|
||||||
run: |
|
run: |
|
||||||
./gradlew clean assembleRelease assembleDebug --no-daemon
|
./gradlew clean assembleDefaultRelease assembleDefaultDebug --no-daemon
|
||||||
|
|
||||||
- name: Verify signatures
|
- name: Verify signatures
|
||||||
run: |
|
run: |
|
||||||
echo "Verify APK signatures"
|
echo "Verify APK signatures"
|
||||||
|
|
@ -68,12 +67,10 @@ jobs:
|
||||||
find app/build/outputs/apk -name '*.apk' -print0 |
|
find app/build/outputs/apk -name '*.apk' -print0 |
|
||||||
while IFS= read -r -d '' line; do
|
while IFS= read -r -d '' line; do
|
||||||
# Wholphin-release-0.2.9-62-g5c12e03-16-arm64-v8a.apk => Wholphin-arm64-v8a.apk
|
# Wholphin-release-0.2.9-62-g5c12e03-16-arm64-v8a.apk => Wholphin-arm64-v8a.apk
|
||||||
short_name="$(echo $line | sed -E 's/-[0-9]+\.[0-9]+\.[0-9]+-[0-9]+-g[a-fA-F0-9]+-[0-9]+//')"
|
short_name="$(echo $line | sed -E 's/-[0-9]+\.[0-9]+\.[0-9]+-[0-9]+-g[a-fA-F0-9]+-[0-9]+//' | sed -E 's/-default//')"
|
||||||
echo "$line => $short_name"
|
echo "$line => $short_name"
|
||||||
cp "$line" "$short_name"
|
cp "$line" "$short_name"
|
||||||
done
|
done
|
||||||
apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//')
|
|
||||||
echo "apks=$apks" >> "$GITHUB_OUTPUT"
|
|
||||||
- name: Checksums
|
- name: Checksums
|
||||||
run: |
|
run: |
|
||||||
echo "SHA256 checksums:"
|
echo "SHA256 checksums:"
|
||||||
|
|
@ -82,6 +79,8 @@ jobs:
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
|
shopt -s globstar
|
||||||
|
|
||||||
gh release delete "${{ env.TAG_NAME }}" --cleanup-tag -y || true
|
gh release delete "${{ env.TAG_NAME }}" --cleanup-tag -y || true
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -98,4 +97,4 @@ jobs:
|
||||||
|
|
||||||
gh release create "${{ env.TAG_NAME }}" \
|
gh release create "${{ env.TAG_NAME }}" \
|
||||||
--latest=false --prerelease --title "${{ env.VERSION_NAME }}" --target "${{ env.BRANCH_NAME }}" -F ci_release_note.txt \
|
--latest=false --prerelease --title "${{ env.VERSION_NAME }}" --target "${{ env.BRANCH_NAME }}" -F ci_release_note.txt \
|
||||||
"app/build/outputs/apk/**/*.apk"
|
app/build/outputs/apk/**/*.apk
|
||||||
|
|
|
||||||
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
|
fetch-depth: 0 # Need the tags to build
|
||||||
- name: Setup
|
- name: Setup
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
- name: Native build
|
|
||||||
uses: ./.github/actions/native-build
|
|
||||||
with:
|
|
||||||
cache: true
|
|
||||||
- name: Build app
|
- name: Build app
|
||||||
id: buildapp
|
id: buildapp
|
||||||
run: |
|
run: |
|
||||||
./gradlew clean assembleDebug testDebugUnitTest --no-daemon
|
./gradlew clean assembleDefaultDebug testDefaultDebugUnitTest --no-daemon
|
||||||
apks=$(find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | tr '\0' ',' | sed 's/,$//')
|
apks=$(find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | tr '\0' ',' | sed 's/,$//')
|
||||||
echo "apks=$apks" >> "$GITHUB_OUTPUT"
|
echo "apks=$apks" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
test-patch:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: pre-commit
|
|
||||||
steps:
|
|
||||||
- name: Checkout the code
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
- name: Test applying patch
|
|
||||||
run: |
|
|
||||||
git apply app/src/patches/play_store.patch
|
|
||||||
git diff
|
|
||||||
|
|
|
||||||
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
|
fetch-depth: 0 # Need the tags to build
|
||||||
- name: Setup
|
- name: Setup
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
- name: Native build
|
|
||||||
uses: ./.github/actions/native-build
|
|
||||||
with:
|
|
||||||
cache: false
|
|
||||||
- name: Build app
|
- name: Build app
|
||||||
id: buildapp
|
id: buildapp
|
||||||
env:
|
env:
|
||||||
|
|
@ -37,21 +33,11 @@ jobs:
|
||||||
KEY_PASSWORD: "${{ secrets.KEY_PASSWORD }}"
|
KEY_PASSWORD: "${{ secrets.KEY_PASSWORD }}"
|
||||||
KEY_STORE_PASSWORD: "${{ secrets.KEY_STORE_PASSWORD }}"
|
KEY_STORE_PASSWORD: "${{ secrets.KEY_STORE_PASSWORD }}"
|
||||||
SIGNING_KEY: "${{ secrets.SIGNING_KEY }}"
|
SIGNING_KEY: "${{ secrets.SIGNING_KEY }}"
|
||||||
|
ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}"
|
||||||
|
ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}"
|
||||||
run: |
|
run: |
|
||||||
./gradlew clean assembleRelease --no-daemon
|
./gradlew clean assembleDefaultRelease bundleAppstoreRelease bundleFiretvRelease --no-daemon
|
||||||
|
|
||||||
- name: Build app
|
|
||||||
id: buildaab
|
|
||||||
env:
|
|
||||||
KEY_ALIAS: "${{ secrets.KEY_ALIAS }}"
|
|
||||||
KEY_PASSWORD: "${{ secrets.KEY_PASSWORD }}"
|
|
||||||
KEY_STORE_PASSWORD: "${{ secrets.KEY_STORE_PASSWORD }}"
|
|
||||||
SIGNING_KEY: "${{ secrets.SIGNING_KEY }}"
|
|
||||||
run: |
|
|
||||||
git apply app/src/patches/play_store.patch
|
|
||||||
./gradlew bundleRelease --no-daemon
|
|
||||||
aab=$(find app/build/outputs -name '*.aab')
|
|
||||||
echo "aab=$aab" >> "$GITHUB_OUTPUT"
|
|
||||||
- name: Verify signatures
|
- name: Verify signatures
|
||||||
run: |
|
run: |
|
||||||
echo "Verify APK signatures"
|
echo "Verify APK signatures"
|
||||||
|
|
@ -62,13 +48,11 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
find app/build/outputs/apk -name '*.apk' -print0 |
|
find app/build/outputs/apk -name '*.apk' -print0 |
|
||||||
while IFS= read -r -d '' line; do
|
while IFS= read -r -d '' line; do
|
||||||
# Wholphin-release-0.2.9-62-g5c12e03-16-arm64-v8a.apk => Wholphin-arm64-v8a.apk
|
# Wholphin-default-release-0.2.9-62-g5c12e03-16-arm64-v8a.apk => Wholphin-arm64-v8a.apk
|
||||||
short_name="$(echo $line | sed -E 's/-release-[0-9]+\.[0-9]+\.[0-9]+-[0-9]+-g[a-fA-F0-9]+-[0-9]+//')"
|
short_name="$(echo $line | sed -E 's/-default-release-[0-9]+\.[0-9]+\.[0-9]+-[0-9]+-g[a-fA-F0-9]+-[0-9]+//')"
|
||||||
echo "$line => $short_name"
|
echo "$line => $short_name"
|
||||||
cp "$line" "$short_name"
|
cp "$line" "$short_name"
|
||||||
done
|
done
|
||||||
apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//')
|
|
||||||
echo "apks=$apks" >> "$GITHUB_OUTPUT"
|
|
||||||
- name: Checksums
|
- name: Checksums
|
||||||
run: |
|
run: |
|
||||||
echo "SHA256 checksums:"
|
echo "SHA256 checksums:"
|
||||||
|
|
@ -84,6 +68,8 @@ jobs:
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
|
shopt -s globstar
|
||||||
|
|
||||||
gh release create "${{ env.TAG_NAME }}" \
|
gh release create "${{ env.TAG_NAME }}" \
|
||||||
--latest --title "${{ env.TAG_NAME }}" --verify-tag -n "" --draft \
|
--latest --title "${{ env.TAG_NAME }}" --verify-tag -n "" --draft \
|
||||||
"app/build/outputs/apk/**/*.apk"
|
app/build/outputs/apk/**/*.apk
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ The app uses:
|
||||||
* [Room](https://developer.android.com/training/data-storage/room) & [DataStore](https://developer.android.com/topic/libraries/architecture/datastore) for local data storage
|
* [Room](https://developer.android.com/training/data-storage/room) & [DataStore](https://developer.android.com/topic/libraries/architecture/datastore) for local data storage
|
||||||
* [Hilt](https://developer.android.com/training/dependency-injection/hilt-android) for dependency injection
|
* [Hilt](https://developer.android.com/training/dependency-injection/hilt-android) for dependency injection
|
||||||
* [Media3/ExoPlayer](https://developer.android.com/media/media3/exoplayer) for media playback
|
* [Media3/ExoPlayer](https://developer.android.com/media/media3/exoplayer) for media playback
|
||||||
|
* [MPV/libmpv](https://github.com/mpv-player/mpv) for media playback
|
||||||
* [Coil](https://coil-kt.github.io/coil/) for image loading
|
* [Coil](https://coil-kt.github.io/coil/) for image loading
|
||||||
* [OkHttp](https://square.github.io/okhttp/) for HTTP requests
|
* [OkHttp](https://square.github.io/okhttp/) for HTTP requests
|
||||||
|
|
||||||
|
|
@ -42,21 +43,13 @@ Code is split into several packages:
|
||||||
- `ui` - User interface code and ViewModels
|
- `ui` - User interface code and ViewModels
|
||||||
- `util` - Utility classes and functions
|
- `util` - Utility classes and functions
|
||||||
|
|
||||||
### Native components
|
## Extensions
|
||||||
|
|
||||||
#### FFmpeg decoder module
|
Wholphin uses several native components for extra playback compatibility. This includes Media3 ffmpeg/av1 decoders and `libmpv`. These extensions are not required to build the app, but without them some functionality will not work.
|
||||||
|
|
||||||
Wholphin ships with [media3 ffmpeg decoder module](https://github.com/androidx/media/blob/release/libraries/decoder_ffmpeg/README.md).
|
If you want to include these in a local build, see the [instructions here](https://github.com/damontecres/wholphin-extensions?tab=readme-ov-file#usage) for configuring the repository.
|
||||||
|
|
||||||
It is not required to build the extension in order to build the app locally.
|
You can also build the extensions locally from https://github.com/damontecres/wholphin-extensions and include them in `app/libs`. The gradle build dependency resolution prefers these local files over fetching from the remote maven registry.
|
||||||
|
|
||||||
You can build the module on MacOS or Linux with the [`build_ffmpeg_decoder.sh`](./scripts/ffmpeg/build_ffmpeg_decoder.sh) script.
|
|
||||||
|
|
||||||
#### MPV player backend
|
|
||||||
|
|
||||||
Wholphin has a playback engine that uses [`libmpv`](https://github.com/mpv-player/mpv). The app uses JNI code from [`mpv-android`](https://github.com/mpv-android/mpv-android) and has an implementation of `androidx.media3.common.Player` to swap out for `ExoPlayer`.
|
|
||||||
|
|
||||||
See the [build scripts](scripts/mpv/) for details on building this component.
|
|
||||||
|
|
||||||
### App settings
|
### App settings
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,8 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin
|
||||||
- Customize the home page to see the content you are interested in
|
- Customize the home page to see the content you are interested in
|
||||||
- Use poster or thumb images, show/hide titles, add/remove/re-order different types of rows!
|
- Use poster or thumb images, show/hide titles, add/remove/re-order different types of rows!
|
||||||
- A navigation drawer for quick access to libraries, favorites, search, and settings from almost anywhere in the app
|
- A navigation drawer for quick access to libraries, favorites, search, and settings from almost anywhere in the app
|
||||||
- Integration with [Jellyseerr](https://github.com/seerr-team/seerr) to discover new movies and TV shows
|
- Integration with [Jellyseerr/Seerr](https://github.com/seerr-team/seerr) to discover new movies and TV shows
|
||||||
|
- Note: only available when installed from [GitHub](https://github.com/damontecres/Wholphin/releases/latest) or the [Play store](https://play.google.com/store/apps/details?id=com.github.damontecres.wholphin)
|
||||||
- Option to combine Continue Watching & Next Up rows
|
- Option to combine Continue Watching & Next Up rows
|
||||||
- Show Movie/TV Show titles when browsing libraries
|
- Show Movie/TV Show titles when browsing libraries
|
||||||
- Play theme music, if available
|
- Play theme music, if available
|
||||||
|
|
@ -58,7 +59,6 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin
|
||||||
- Trickplay support
|
- Trickplay support
|
||||||
- Subtly show playback position along the bottom of the screen while seeking w/ D-Pad
|
- Subtly show playback position along the bottom of the screen while seeking w/ D-Pad
|
||||||
|
|
||||||
|
|
||||||
### Roadmap
|
### Roadmap
|
||||||
|
|
||||||
See [here for the roadmap](https://github.com/damontecres/Wholphin/wiki#roadmap)
|
See [here for the roadmap](https://github.com/damontecres/Wholphin/wiki#roadmap)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import com.android.build.api.dsl.ProductFlavor
|
||||||
import com.google.protobuf.gradle.id
|
import com.google.protobuf.gradle.id
|
||||||
import com.mikepenz.aboutlibraries.plugin.DuplicateMode
|
import com.mikepenz.aboutlibraries.plugin.DuplicateMode
|
||||||
import com.mikepenz.aboutlibraries.plugin.DuplicateRule
|
import com.mikepenz.aboutlibraries.plugin.DuplicateRule
|
||||||
|
|
@ -21,6 +22,8 @@ val isCI = if (System.getenv("CI") != null) System.getenv("CI").toBoolean() else
|
||||||
val shouldSign = isCI && System.getenv("KEY_ALIAS") != null
|
val shouldSign = isCI && System.getenv("KEY_ALIAS") != null
|
||||||
val ffmpegModuleExists = project.file("libs/lib-decoder-ffmpeg-release.aar").exists()
|
val ffmpegModuleExists = project.file("libs/lib-decoder-ffmpeg-release.aar").exists()
|
||||||
val av1ModuleExists = project.file("libs/lib-decoder-av1-release.aar").exists()
|
val av1ModuleExists = project.file("libs/lib-decoder-av1-release.aar").exists()
|
||||||
|
val mpvModuleExists = project.file("libs/wholphin-mpv-release.aar").exists()
|
||||||
|
val extensionsRepoActive = project.hasProperty("WholphinExtensionsUsername")
|
||||||
|
|
||||||
val gitTags =
|
val gitTags =
|
||||||
providers
|
providers
|
||||||
|
|
@ -45,6 +48,8 @@ android {
|
||||||
versionCode = gitTags.trim().lines().size
|
versionCode = gitTags.trim().lines().size
|
||||||
versionName = gitDescribe.trim().removePrefix("v").ifBlank { "0.0.0" }
|
versionName = gitDescribe.trim().removePrefix("v").ifBlank { "0.0.0" }
|
||||||
testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner"
|
testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner"
|
||||||
|
|
||||||
|
buildConfigField("long", "BUILD_TIME", System.currentTimeMillis().toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|
@ -106,6 +111,38 @@ android {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
flavorDimensions += "version"
|
||||||
|
productFlavors {
|
||||||
|
val featureLeanback = "leanback"
|
||||||
|
val featureUpdate = "UPDATING_ENABLED"
|
||||||
|
val featureDiscover = "DISCOVER_ENABLED"
|
||||||
|
|
||||||
|
fun ProductFlavor.setFeatureFlag(
|
||||||
|
name: String,
|
||||||
|
enabled: Boolean,
|
||||||
|
) {
|
||||||
|
this.buildConfigField("boolean", name, "Boolean.parseBoolean(\"${enabled}\")")
|
||||||
|
}
|
||||||
|
create("default") {
|
||||||
|
dimension = "version"
|
||||||
|
isDefault = true
|
||||||
|
manifestPlaceholders += mapOf(featureLeanback to false)
|
||||||
|
setFeatureFlag(featureUpdate, true)
|
||||||
|
setFeatureFlag(featureDiscover, true)
|
||||||
|
}
|
||||||
|
create("appstore") {
|
||||||
|
dimension = "version"
|
||||||
|
manifestPlaceholders += mapOf(featureLeanback to true)
|
||||||
|
setFeatureFlag(featureUpdate, false)
|
||||||
|
setFeatureFlag(featureDiscover, true)
|
||||||
|
}
|
||||||
|
create("firetv") {
|
||||||
|
dimension = "version"
|
||||||
|
manifestPlaceholders += mapOf(featureLeanback to true)
|
||||||
|
setFeatureFlag(featureUpdate, false)
|
||||||
|
setFeatureFlag(featureDiscover, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
|
|
@ -134,6 +171,12 @@ android {
|
||||||
isUniversalApk = true
|
isUniversalApk = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
packaging {
|
||||||
|
jniLibs {
|
||||||
|
// Work around because libass-android & wholphin-mpv both (incorrectly) package libc++_shared.so
|
||||||
|
pickFirsts += "lib/*/libc++_shared.so"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
getByName("main") {
|
getByName("main") {
|
||||||
|
|
@ -146,6 +189,10 @@ android {
|
||||||
isIncludeAndroidResources = true
|
isIncludeAndroidResources = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lint {
|
||||||
|
disable.add("MissingTranslation")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protobuf {
|
protobuf {
|
||||||
|
|
@ -219,6 +266,7 @@ dependencies {
|
||||||
implementation(libs.androidx.lifecycle.livedata.ktx)
|
implementation(libs.androidx.lifecycle.livedata.ktx)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(libs.androidx.datastore)
|
implementation(libs.androidx.datastore)
|
||||||
|
implementation(libs.androidx.datastore.preferences)
|
||||||
implementation(libs.protobuf.kotlin.lite)
|
implementation(libs.protobuf.kotlin.lite)
|
||||||
implementation(libs.androidx.tvprovider)
|
implementation(libs.androidx.tvprovider)
|
||||||
implementation(libs.androidx.work.runtime.ktx)
|
implementation(libs.androidx.work.runtime.ktx)
|
||||||
|
|
@ -231,6 +279,7 @@ dependencies {
|
||||||
implementation(libs.androidx.media3.exoplayer.dash)
|
implementation(libs.androidx.media3.exoplayer.dash)
|
||||||
implementation(libs.androidx.media3.ui)
|
implementation(libs.androidx.media3.ui)
|
||||||
implementation(libs.androidx.media3.ui.compose)
|
implementation(libs.androidx.media3.ui.compose)
|
||||||
|
implementation(libs.ass.media)
|
||||||
|
|
||||||
implementation(libs.coil.core)
|
implementation(libs.coil.core)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
|
|
@ -287,11 +336,33 @@ dependencies {
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||||
coreLibraryDesugaring(libs.desugar.jdk.libs)
|
coreLibraryDesugaring(libs.desugar.jdk.libs)
|
||||||
if (ffmpegModuleExists || isCI) {
|
|
||||||
|
if (ffmpegModuleExists) {
|
||||||
|
logger.info("Using local ffmpeg decoder")
|
||||||
implementation(files("libs/lib-decoder-ffmpeg-release.aar"))
|
implementation(files("libs/lib-decoder-ffmpeg-release.aar"))
|
||||||
|
} else if (extensionsRepoActive) {
|
||||||
|
logger.info("Using prebuilt ffmpeg decoder")
|
||||||
|
implementation(libs.wholphin.extensions.ffmpeg)
|
||||||
|
} else {
|
||||||
|
logger.warn("Media3 ffmpeg decoder was NOT found")
|
||||||
}
|
}
|
||||||
if (av1ModuleExists || isCI) {
|
if (av1ModuleExists) {
|
||||||
|
logger.info("Using local av1 decoder")
|
||||||
implementation(files("libs/lib-decoder-av1-release.aar"))
|
implementation(files("libs/lib-decoder-av1-release.aar"))
|
||||||
|
} else if (extensionsRepoActive) {
|
||||||
|
logger.info("Using prebuilt av1 decoder")
|
||||||
|
implementation(libs.wholphin.extensions.av1)
|
||||||
|
} else {
|
||||||
|
logger.warn("Media3 av1 decoder was NOT found")
|
||||||
|
}
|
||||||
|
if (mpvModuleExists) {
|
||||||
|
logger.info("Using local libMPV build")
|
||||||
|
implementation(files("libs/wholphin-mpv-release.aar"))
|
||||||
|
} else if (extensionsRepoActive) {
|
||||||
|
logger.info("Using prebuilt libMPV")
|
||||||
|
implementation(libs.wholphin.extensions.mpv)
|
||||||
|
} else {
|
||||||
|
logger.warn("libMPV was NOT found")
|
||||||
}
|
}
|
||||||
|
|
||||||
testImplementation(libs.mockk.android)
|
testImplementation(libs.mockk.android)
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,9 @@ import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.performKeyInput
|
import androidx.compose.ui.test.performKeyInput
|
||||||
import androidx.compose.ui.test.pressKey
|
import androidx.compose.ui.test.pressKey
|
||||||
|
import androidx.navigation3.runtime.NavBackStack
|
||||||
import com.github.damontecres.wholphin.MainContent
|
import com.github.damontecres.wholphin.MainContent
|
||||||
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.services.ScreensaverService
|
import com.github.damontecres.wholphin.services.ScreensaverService
|
||||||
import com.github.damontecres.wholphin.services.ScreensaverState
|
import com.github.damontecres.wholphin.services.ScreensaverState
|
||||||
import com.github.damontecres.wholphin.services.SetupDestination
|
import com.github.damontecres.wholphin.services.SetupDestination
|
||||||
|
|
@ -43,16 +45,17 @@ class InstrumentedBasicUiTests {
|
||||||
@OptIn(ExperimentalTestApi::class)
|
@OptIn(ExperimentalTestApi::class)
|
||||||
@Test
|
@Test
|
||||||
fun myTest() {
|
fun myTest() {
|
||||||
|
val navigationManager = NavigationManager()
|
||||||
|
navigationManager.backStack = NavBackStack(Destination.Home())
|
||||||
// Start the app
|
// Start the app
|
||||||
composeTestRule.setContent {
|
composeTestRule.setContent {
|
||||||
WholphinTheme {
|
WholphinTheme {
|
||||||
MainContent(
|
MainContent(
|
||||||
backStack = mutableListOf(SetupDestination.ServerList),
|
backStack = mutableListOf(SetupDestination.ServerList),
|
||||||
navigationManager = mockk(relaxed = true),
|
navigationManager = navigationManager,
|
||||||
appPreferences = mockk(relaxed = true),
|
appPreferences = mockk(relaxed = true),
|
||||||
backdropService = mockk(relaxed = true),
|
backdropService = mockk(relaxed = true),
|
||||||
screensaverService = screensaverService,
|
screensaverService = screensaverService,
|
||||||
requestedDestination = Destination.Home(),
|
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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" />
|
android:required="false" />
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.software.leanback"
|
android:name="android.software.leanback"
|
||||||
android:required="false" />
|
android:required="${leanback}" />
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.hardware.microphone"
|
android:name="android.hardware.microphone"
|
||||||
android:required="false" />
|
android:required="false" />
|
||||||
|
|
@ -30,6 +30,10 @@
|
||||||
<intent>
|
<intent>
|
||||||
<action android:name="android.speech.action.RECOGNIZE_SPEECH" />
|
<action android:name="android.speech.action.RECOGNIZE_SPEECH" />
|
||||||
</intent>
|
</intent>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<data android:mimeType="video/*" />
|
||||||
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.github.damontecres.wholphin
|
package com.github.damontecres.wholphin
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
|
@ -24,15 +25,18 @@ import androidx.datastore.core.DataStore
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.navigation3.runtime.NavBackStack
|
||||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||||
import com.github.damontecres.wholphin.services.AppUpgradeHandler
|
import com.github.damontecres.wholphin.services.AppUpgradeHandler
|
||||||
import com.github.damontecres.wholphin.services.BackdropService
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.DatePlayedInvalidationService
|
import com.github.damontecres.wholphin.services.DatePlayedInvalidationService
|
||||||
import com.github.damontecres.wholphin.services.DeviceProfileService
|
import com.github.damontecres.wholphin.services.DeviceProfileService
|
||||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||||
|
import com.github.damontecres.wholphin.services.LatestNextUpSchedulerService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver
|
import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver
|
||||||
import com.github.damontecres.wholphin.services.RefreshRateService
|
import com.github.damontecres.wholphin.services.RefreshRateService
|
||||||
|
|
@ -50,14 +54,15 @@ import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
||||||
import com.github.damontecres.wholphin.ui.launchDefault
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import com.github.damontecres.wholphin.ui.showToast
|
||||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
|
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
|
||||||
import com.github.damontecres.wholphin.util.DebugLogTree
|
import com.github.damontecres.wholphin.util.DebugLogTree
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
|
|
@ -66,7 +71,9 @@ import kotlinx.coroutines.flow.firstOrNull
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
|
|
@ -111,6 +118,9 @@ class MainActivity : AppCompatActivity() {
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var suggestionsSchedulerService: SuggestionsSchedulerService
|
lateinit var suggestionsSchedulerService: SuggestionsSchedulerService
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var latestNextUpSchedulerService: LatestNextUpSchedulerService
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var backdropService: BackdropService
|
lateinit var backdropService: BackdropService
|
||||||
|
|
||||||
|
|
@ -127,6 +137,11 @@ class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
private var signInAuto = true
|
private var signInAuto = true
|
||||||
|
|
||||||
|
private val json =
|
||||||
|
Json {
|
||||||
|
classDiscriminator = "_type"
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
@ -134,6 +149,26 @@ class MainActivity : AppCompatActivity() {
|
||||||
Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}")
|
Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}")
|
||||||
lifecycle.addObserver(playbackLifecycleObserver)
|
lifecycle.addObserver(playbackLifecycleObserver)
|
||||||
|
|
||||||
|
val backStackStr = savedInstanceState?.getString(KEY_BACK_STACK)
|
||||||
|
if (backStackStr != null) {
|
||||||
|
Timber.d("Restoring back stack")
|
||||||
|
var backStack = json.decodeFromString<List<Destination>>(backStackStr)
|
||||||
|
if (!savedInstanceState.getBoolean(KEY_EXTERNAL_PLAYER)) {
|
||||||
|
val lastDest = backStack.lastOrNull()
|
||||||
|
if (lastDest is Destination.Playback ||
|
||||||
|
lastDest is Destination.PlaybackList ||
|
||||||
|
lastDest is Destination.Slideshow
|
||||||
|
) {
|
||||||
|
Timber.v("Restoring back stack with playback")
|
||||||
|
backStack = backStack.toMutableList().apply { removeAt(lastIndex) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
navigationManager.backStack = NavBackStack(*backStack.toTypedArray())
|
||||||
|
} else {
|
||||||
|
val startDestination = intent?.let(::extractDestination) ?: Destination.Home()
|
||||||
|
navigationManager.backStack = NavBackStack(startDestination)
|
||||||
|
}
|
||||||
|
|
||||||
viewModel.serverRepository.currentUser.observe(this) { user ->
|
viewModel.serverRepository.currentUser.observe(this) { user ->
|
||||||
if (user?.hasPin == true) {
|
if (user?.hasPin == true) {
|
||||||
window?.setFlags(
|
window?.setFlags(
|
||||||
|
|
@ -206,17 +241,12 @@ class MainActivity : AppCompatActivity() {
|
||||||
appThemeColors = appPreferences.interfacePreferences.appThemeColors,
|
appThemeColors = appPreferences.interfacePreferences.appThemeColors,
|
||||||
) {
|
) {
|
||||||
ProvideLocalClock {
|
ProvideLocalClock {
|
||||||
val requestedDestination =
|
|
||||||
remember(intent) {
|
|
||||||
intent?.let(::extractDestination) ?: Destination.Home()
|
|
||||||
}
|
|
||||||
MainContent(
|
MainContent(
|
||||||
backStack = setupNavigationManager.backStack,
|
backStack = setupNavigationManager.backStack,
|
||||||
navigationManager = navigationManager,
|
navigationManager = navigationManager,
|
||||||
appPreferences = appPreferences,
|
appPreferences = appPreferences,
|
||||||
backdropService = backdropService,
|
backdropService = backdropService,
|
||||||
screensaverService = screensaverService,
|
screensaverService = screensaverService,
|
||||||
requestedDestination = requestedDestination,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -287,6 +317,11 @@ class MainActivity : AppCompatActivity() {
|
||||||
override fun onSaveInstanceState(outState: Bundle) {
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
super.onSaveInstanceState(outState)
|
super.onSaveInstanceState(outState)
|
||||||
Timber.d("onSaveInstanceState")
|
Timber.d("onSaveInstanceState")
|
||||||
|
val str = json.encodeToString(navigationManager.backStack.toList())
|
||||||
|
outState.putString(KEY_BACK_STACK, str)
|
||||||
|
val playerBackend =
|
||||||
|
runBlocking { userPreferencesDataStore.data.firstOrNull() }?.playbackPreferences?.playerBackend
|
||||||
|
outState.putBoolean(KEY_EXTERNAL_PLAYER, playerBackend == PlayerBackend.EXTERNAL_PLAYER)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||||
|
|
@ -362,6 +397,9 @@ class MainActivity : AppCompatActivity() {
|
||||||
const val INTENT_SEASON_NUMBER = "seaNum"
|
const val INTENT_SEASON_NUMBER = "seaNum"
|
||||||
const val INTENT_SEASON_ID = "seaId"
|
const val INTENT_SEASON_ID = "seaId"
|
||||||
|
|
||||||
|
private const val KEY_BACK_STACK = "backStack"
|
||||||
|
private const val KEY_EXTERNAL_PLAYER = "extPlayer"
|
||||||
|
|
||||||
lateinit var instance: MainActivity
|
lateinit var instance: MainActivity
|
||||||
private set
|
private set
|
||||||
}
|
}
|
||||||
|
|
@ -371,6 +409,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
class MainActivityViewModel
|
class MainActivityViewModel
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
private val preferences: DataStore<AppPreferences>,
|
private val preferences: DataStore<AppPreferences>,
|
||||||
val serverRepository: ServerRepository,
|
val serverRepository: ServerRepository,
|
||||||
private val navigationManager: SetupNavigationManager,
|
private val navigationManager: SetupNavigationManager,
|
||||||
|
|
@ -379,9 +418,19 @@ class MainActivityViewModel
|
||||||
private val appUpgradeHandler: AppUpgradeHandler,
|
private val appUpgradeHandler: AppUpgradeHandler,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
fun appStart() {
|
fun appStart() {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchDefault {
|
||||||
try {
|
try {
|
||||||
appUpgradeHandler.run()
|
val needUpgrade = appUpgradeHandler.needUpgrade()
|
||||||
|
if (needUpgrade) {
|
||||||
|
showToast(
|
||||||
|
context,
|
||||||
|
context.getString(
|
||||||
|
R.string.updated_toast,
|
||||||
|
appUpgradeHandler.currentVersion.toString(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
appUpgradeHandler.run()
|
||||||
|
}
|
||||||
appUpgradeHandler.copySubfont(false)
|
appUpgradeHandler.copySubfont(false)
|
||||||
val prefs =
|
val prefs =
|
||||||
preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
|
preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
|
||||||
|
|
@ -424,7 +473,7 @@ class MainActivityViewModel
|
||||||
navigationManager.navigateTo(SetupDestination.ServerList)
|
navigationManager.navigateTo(SetupDestination.ServerList)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchDefault {
|
||||||
// Create the mediaCodecCapabilitiesTest if needed
|
// Create the mediaCodecCapabilitiesTest if needed
|
||||||
deviceProfileService.mediaCodecCapabilitiesTest.supportsAVC()
|
deviceProfileService.mediaCodecCapabilitiesTest.supportsAVC()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -33,10 +34,8 @@ import com.github.damontecres.wholphin.services.ScreensaverService
|
||||||
import com.github.damontecres.wholphin.services.SetupDestination
|
import com.github.damontecres.wholphin.services.SetupDestination
|
||||||
import com.github.damontecres.wholphin.ui.components.AppScreensaver
|
import com.github.damontecres.wholphin.ui.components.AppScreensaver
|
||||||
import com.github.damontecres.wholphin.ui.nav.ApplicationContent
|
import com.github.damontecres.wholphin.ui.nav.ApplicationContent
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
|
||||||
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
|
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
|
||||||
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
|
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
|
||||||
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainContent(
|
fun MainContent(
|
||||||
|
|
@ -45,9 +44,9 @@ fun MainContent(
|
||||||
appPreferences: AppPreferences,
|
appPreferences: AppPreferences,
|
||||||
backdropService: BackdropService,
|
backdropService: BackdropService,
|
||||||
screensaverService: ScreensaverService,
|
screensaverService: ScreensaverService,
|
||||||
requestedDestination: Destination,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val preferences by rememberUpdatedState(UserPreferences(appPreferences))
|
||||||
Surface(
|
Surface(
|
||||||
modifier =
|
modifier =
|
||||||
modifier
|
modifier
|
||||||
|
|
@ -96,10 +95,6 @@ fun MainContent(
|
||||||
backdropService.clearBackdrop()
|
backdropService.clearBackdrop()
|
||||||
}
|
}
|
||||||
val current = key.current
|
val current = key.current
|
||||||
val preferences =
|
|
||||||
remember(appPreferences) {
|
|
||||||
UserPreferences(appPreferences)
|
|
||||||
}
|
|
||||||
var showContent by remember {
|
var showContent by remember {
|
||||||
mutableStateOf(true)
|
mutableStateOf(true)
|
||||||
}
|
}
|
||||||
|
|
@ -113,7 +108,6 @@ fun MainContent(
|
||||||
ApplicationContent(
|
ApplicationContent(
|
||||||
user = current.user,
|
user = current.user,
|
||||||
server = current.server,
|
server = current.server,
|
||||||
startDestination = requestedDestination,
|
|
||||||
navigationManager = navigationManager,
|
navigationManager = navigationManager,
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,7 @@ import androidx.savedstate.SavedStateRegistryOwner
|
||||||
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
|
||||||
import com.github.damontecres.wholphin.services.ScreensaverService
|
import com.github.damontecres.wholphin.services.ScreensaverService
|
||||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
|
||||||
import com.github.damontecres.wholphin.ui.components.AppScreensaverContent
|
import com.github.damontecres.wholphin.ui.components.AppScreensaverContent
|
||||||
import com.github.damontecres.wholphin.ui.launchDefault
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
|
|
@ -33,6 +31,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
|
@ -61,6 +60,7 @@ class WholphinDreamService :
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
Timber.d("onCreate")
|
||||||
|
|
||||||
savedStateRegistryController.performRestore(null)
|
savedStateRegistryController.performRestore(null)
|
||||||
lifecycleRegistry.currentState = Lifecycle.State.CREATED
|
lifecycleRegistry.currentState = Lifecycle.State.CREATED
|
||||||
|
|
@ -74,6 +74,7 @@ class WholphinDreamService :
|
||||||
|
|
||||||
override fun onAttachedToWindow() {
|
override fun onAttachedToWindow() {
|
||||||
super.onAttachedToWindow()
|
super.onAttachedToWindow()
|
||||||
|
Timber.d("onAttachedToWindow")
|
||||||
val itemFlow = screensaverService.createItemFlow(lifecycleScope)
|
val itemFlow = screensaverService.createItemFlow(lifecycleScope)
|
||||||
setContentView(
|
setContentView(
|
||||||
ComposeView(this).apply {
|
ComposeView(this).apply {
|
||||||
|
|
@ -109,11 +110,13 @@ class WholphinDreamService :
|
||||||
|
|
||||||
override fun onDreamingStarted() {
|
override fun onDreamingStarted() {
|
||||||
super.onDreamingStarted()
|
super.onDreamingStarted()
|
||||||
|
Timber.d("onDreamingStarted")
|
||||||
lifecycleRegistry.currentState = Lifecycle.State.STARTED
|
lifecycleRegistry.currentState = Lifecycle.State.STARTED
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDreamingStopped() {
|
override fun onDreamingStopped() {
|
||||||
super.onDreamingStopped()
|
super.onDreamingStopped()
|
||||||
|
Timber.d("onDreamingStopped")
|
||||||
lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
|
lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,18 @@ import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import org.jellyfin.sdk.model.UUID
|
import org.jellyfin.sdk.model.UUID
|
||||||
import org.jellyfin.sdk.model.api.ExtraType
|
import org.jellyfin.sdk.model.api.ExtraType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents "extras" for media such as behind-the-scenes or deleted scenes
|
||||||
|
*/
|
||||||
sealed interface ExtrasItem {
|
sealed interface ExtrasItem {
|
||||||
val parentId: UUID
|
val parentId: UUID
|
||||||
val type: ExtraType
|
val type: ExtraType
|
||||||
val destination: Destination
|
val destination: Destination
|
||||||
val title: String?
|
val title: String?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents multiple extras of the same type
|
||||||
|
*/
|
||||||
data class Group(
|
data class Group(
|
||||||
override val parentId: UUID,
|
override val parentId: UUID,
|
||||||
override val type: ExtraType,
|
override val type: ExtraType,
|
||||||
|
|
@ -25,6 +31,9 @@ sealed interface ExtrasItem {
|
||||||
override val title: String? = null
|
override val title: String? = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a single extra
|
||||||
|
*/
|
||||||
data class Single(
|
data class Single(
|
||||||
override val parentId: UUID,
|
override val parentId: UUID,
|
||||||
override val type: ExtraType,
|
override val type: ExtraType,
|
||||||
|
|
@ -38,6 +47,9 @@ sealed interface ExtrasItem {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts [ExtraType] to the string resource ID
|
||||||
|
*/
|
||||||
@get:StringRes
|
@get:StringRes
|
||||||
val ExtraType.stringRes: Int
|
val ExtraType.stringRes: Int
|
||||||
get() =
|
get() =
|
||||||
|
|
@ -56,6 +68,9 @@ val ExtraType.stringRes: Int
|
||||||
ExtraType.SHORT -> R.string.shorts
|
ExtraType.SHORT -> R.string.shorts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts [ExtraType] to the plural resource ID
|
||||||
|
*/
|
||||||
@get:PluralsRes
|
@get:PluralsRes
|
||||||
val ExtraType.pluralRes: Int
|
val ExtraType.pluralRes: Int
|
||||||
get() =
|
get() =
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import androidx.room.Query
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
|
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
|
||||||
import com.github.damontecres.wholphin.ui.toServerString
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
|
|
@ -27,6 +28,12 @@ interface LibraryDisplayInfoDao {
|
||||||
itemId: String,
|
itemId: String,
|
||||||
): LibraryDisplayInfo?
|
): LibraryDisplayInfo?
|
||||||
|
|
||||||
|
@Query("SELECT * from LibraryDisplayInfo WHERE userId=:userId AND itemId=:itemId")
|
||||||
|
fun getItemAsFlow(
|
||||||
|
userId: Int,
|
||||||
|
itemId: String,
|
||||||
|
): Flow<LibraryDisplayInfo?>
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
fun saveItem(item: LibraryDisplayInfo): Long
|
fun saveItem(item: LibraryDisplayInfo): Long
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,19 @@ val DefaultFilterOptions =
|
||||||
DecadeFilter,
|
DecadeFilter,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val DefaultTvFilterOptions =
|
||||||
|
listOf(
|
||||||
|
PlayedFilter,
|
||||||
|
FavoriteFilter,
|
||||||
|
GenreFilter,
|
||||||
|
StudioFilter,
|
||||||
|
CommunityRatingFilter,
|
||||||
|
OfficialRatingFilter,
|
||||||
|
VideoTypeFilter,
|
||||||
|
YearFilter,
|
||||||
|
DecadeFilter,
|
||||||
|
)
|
||||||
|
|
||||||
val DefaultForFavoritesFilterOptions =
|
val DefaultForFavoritesFilterOptions =
|
||||||
listOf(
|
listOf(
|
||||||
PlayedFilter,
|
PlayedFilter,
|
||||||
|
|
@ -32,6 +45,19 @@ val DefaultForGenresFilterOptions =
|
||||||
listOf(
|
listOf(
|
||||||
PlayedFilter,
|
PlayedFilter,
|
||||||
FavoriteFilter,
|
FavoriteFilter,
|
||||||
|
StudioFilter,
|
||||||
|
CommunityRatingFilter,
|
||||||
|
OfficialRatingFilter,
|
||||||
|
VideoTypeFilter,
|
||||||
|
YearFilter,
|
||||||
|
DecadeFilter,
|
||||||
|
)
|
||||||
|
|
||||||
|
val DefaultForStudiosFilterOptions =
|
||||||
|
listOf(
|
||||||
|
PlayedFilter,
|
||||||
|
FavoriteFilter,
|
||||||
|
GenreFilter,
|
||||||
CommunityRatingFilter,
|
CommunityRatingFilter,
|
||||||
OfficialRatingFilter,
|
OfficialRatingFilter,
|
||||||
VideoTypeFilter,
|
VideoTypeFilter,
|
||||||
|
|
@ -50,6 +76,11 @@ val DefaultPlaylistItemsOptions =
|
||||||
DecadeFilter,
|
DecadeFilter,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A way to filter libraries
|
||||||
|
*
|
||||||
|
* Gets and sets values within a [GetItemsFilter]
|
||||||
|
*/
|
||||||
sealed interface ItemFilterBy<T> {
|
sealed interface ItemFilterBy<T> {
|
||||||
@get:StringRes
|
@get:StringRes
|
||||||
val stringRes: Int
|
val stringRes: Int
|
||||||
|
|
@ -178,3 +209,16 @@ data object CommunityRatingFilter : ItemFilterBy<Int> {
|
||||||
filter: GetItemsFilter,
|
filter: GetItemsFilter,
|
||||||
): GetItemsFilter = filter.copy(minCommunityRating = value?.toDouble())
|
): GetItemsFilter = filter.copy(minCommunityRating = value?.toDouble())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data object StudioFilter : ItemFilterBy<List<UUID>> {
|
||||||
|
override val stringRes: Int = R.string.studios
|
||||||
|
|
||||||
|
override val supportMultiple: Boolean = true
|
||||||
|
|
||||||
|
override fun get(filter: GetItemsFilter): List<UUID>? = filter.studios
|
||||||
|
|
||||||
|
override fun set(
|
||||||
|
value: List<UUID>?,
|
||||||
|
filter: GetItemsFilter,
|
||||||
|
): GetItemsFilter = filter.copy(studios = value)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.runtime.Stable
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import com.github.damontecres.wholphin.ui.DateFormatter
|
|
||||||
import com.github.damontecres.wholphin.ui.abbreviateNumber
|
import com.github.damontecres.wholphin.ui.abbreviateNumber
|
||||||
import com.github.damontecres.wholphin.ui.detail.CardGridItem
|
import com.github.damontecres.wholphin.ui.detail.CardGridItem
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.music.artistsString
|
||||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
||||||
import com.github.damontecres.wholphin.ui.dot
|
import com.github.damontecres.wholphin.ui.dot
|
||||||
import com.github.damontecres.wholphin.ui.formatDateTime
|
import com.github.damontecres.wholphin.ui.formatDateTime
|
||||||
|
import com.github.damontecres.wholphin.ui.getDateFormatter
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.playback.playable
|
import com.github.damontecres.wholphin.ui.playback.playable
|
||||||
import com.github.damontecres.wholphin.ui.roundMinutes
|
import com.github.damontecres.wholphin.ui.roundMinutes
|
||||||
|
|
@ -28,11 +29,14 @@ import java.util.Locale
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper for [BaseItemDto] with shortcuts for various UI elements
|
||||||
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
@Stable
|
@Stable
|
||||||
data class BaseItem(
|
data class BaseItem(
|
||||||
val data: BaseItemDto,
|
val data: BaseItemDto,
|
||||||
val useSeriesForPrimary: Boolean,
|
val useSeriesForPrimary: Boolean = false,
|
||||||
val imageUrlOverride: String? = null,
|
val imageUrlOverride: String? = null,
|
||||||
val destinationOverride: Destination? = null,
|
val destinationOverride: Destination? = null,
|
||||||
) : CardGridItem {
|
) : CardGridItem {
|
||||||
|
|
@ -57,6 +61,7 @@ data class BaseItem(
|
||||||
when (type) {
|
when (type) {
|
||||||
BaseItemKind.EPISODE -> data.seasonEpisode + " - " + name
|
BaseItemKind.EPISODE -> data.seasonEpisode + " - " + name
|
||||||
BaseItemKind.SERIES -> data.seriesProductionYears
|
BaseItemKind.SERIES -> data.seriesProductionYears
|
||||||
|
BaseItemKind.AUDIO -> listOfNotNull(data.album, artistsString).joinToString(" - ")
|
||||||
else -> data.productionYear?.toString()
|
else -> data.productionYear?.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,8 +79,7 @@ data class BaseItem(
|
||||||
|
|
||||||
val canDelete: Boolean get() = data.canDelete == true
|
val canDelete: Boolean get() = data.canDelete == true
|
||||||
|
|
||||||
@Transient
|
val aspectRatio: Float? get() = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 }
|
||||||
val aspectRatio: Float? = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 }
|
|
||||||
|
|
||||||
val indexNumber get() = data.indexNumber
|
val indexNumber get() = data.indexNumber
|
||||||
|
|
||||||
|
|
@ -87,9 +91,11 @@ data class BaseItem(
|
||||||
|
|
||||||
val favorite get() = data.userData?.isFavorite ?: false
|
val favorite get() = data.userData?.isFavorite ?: false
|
||||||
|
|
||||||
@Transient
|
val timeRemainingOrRuntime: Duration? get() = data.timeRemaining ?: data.runTimeTicks?.ticks
|
||||||
val timeRemainingOrRuntime: Duration? = data.timeRemaining ?: data.runTimeTicks?.ticks
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contains pre computed UI elements that would be expensive to create on the main thread
|
||||||
|
*/
|
||||||
@Transient
|
@Transient
|
||||||
val ui =
|
val ui =
|
||||||
BaseItemUi(
|
BaseItemUi(
|
||||||
|
|
@ -116,7 +122,7 @@ data class BaseItem(
|
||||||
buildList {
|
buildList {
|
||||||
if (type == BaseItemKind.EPISODE) {
|
if (type == BaseItemKind.EPISODE) {
|
||||||
data.seasonEpisode?.let(::add)
|
data.seasonEpisode?.let(::add)
|
||||||
data.premiereDate?.let { add(DateFormatter.format(it)) }
|
data.premiereDate?.let { add(getDateFormatter().format(it)) }
|
||||||
} else if (type == BaseItemKind.SERIES) {
|
} else if (type == BaseItemKind.SERIES) {
|
||||||
data.seriesProductionYears?.let(::add)
|
data.seriesProductionYears?.let(::add)
|
||||||
} else if (type == BaseItemKind.PHOTO) {
|
} else if (type == BaseItemKind.PHOTO) {
|
||||||
|
|
@ -125,6 +131,9 @@ data class BaseItem(
|
||||||
} else if (data.premiereDate != null) {
|
} else if (data.premiereDate != null) {
|
||||||
add(data.premiereDate!!.toLocalDate().toString())
|
add(data.premiereDate!!.toLocalDate().toString())
|
||||||
}
|
}
|
||||||
|
} else if (type == BaseItemKind.BOX_SET) {
|
||||||
|
data.productionYear?.let { add(it.toString()) }
|
||||||
|
data.childCount?.let { add("$it items") }
|
||||||
} else {
|
} else {
|
||||||
data.productionYear?.let { add(it.toString()) }
|
data.productionYear?.let { add(it.toString()) }
|
||||||
}
|
}
|
||||||
|
|
@ -173,6 +182,9 @@ data class BaseItem(
|
||||||
it.dayOfMonth.toString().padStart(2, '0')
|
it.dayOfMonth.toString().padStart(2, '0')
|
||||||
}?.toIntOrNull()
|
}?.toIntOrNull()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert this [BaseItem] into a [Destination] to navigate to its page in the app
|
||||||
|
*/
|
||||||
fun destination(index: Int? = null): Destination {
|
fun destination(index: Int? = null): Destination {
|
||||||
if (destinationOverride != null) return destinationOverride
|
if (destinationOverride != null) return destinationOverride
|
||||||
val result =
|
val result =
|
||||||
|
|
@ -223,6 +235,7 @@ data class BaseItem(
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
@Deprecated("Use regular constructor instead")
|
||||||
fun from(
|
fun from(
|
||||||
dto: BaseItemDto,
|
dto: BaseItemDto,
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
|
|
@ -244,6 +257,9 @@ data class BaseItemUi(
|
||||||
val quickDetails: AnnotatedString,
|
val quickDetails: AnnotatedString,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the special [Destination.FilteredCollection] for the given genre information
|
||||||
|
*/
|
||||||
fun createGenreDestination(
|
fun createGenreDestination(
|
||||||
genreId: UUID,
|
genreId: UUID,
|
||||||
genreName: String,
|
genreName: String,
|
||||||
|
|
@ -252,6 +268,7 @@ fun createGenreDestination(
|
||||||
includeItemTypes: List<BaseItemKind>?,
|
includeItemTypes: List<BaseItemKind>?,
|
||||||
) = Destination.FilteredCollection(
|
) = Destination.FilteredCollection(
|
||||||
itemId = parentId,
|
itemId = parentId,
|
||||||
|
parentType = BaseItemKind.GENRE,
|
||||||
filter =
|
filter =
|
||||||
CollectionFolderFilter(
|
CollectionFolderFilter(
|
||||||
nameOverride =
|
nameOverride =
|
||||||
|
|
@ -268,3 +285,31 @@ fun createGenreDestination(
|
||||||
),
|
),
|
||||||
recursive = true,
|
recursive = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun createStudioDestination(
|
||||||
|
studioId: UUID,
|
||||||
|
name: String,
|
||||||
|
parentId: UUID,
|
||||||
|
parentName: String?,
|
||||||
|
includeItemTypes: List<BaseItemKind>?,
|
||||||
|
) = Destination.FilteredCollection(
|
||||||
|
itemId = parentId,
|
||||||
|
parentType = BaseItemKind.STUDIO,
|
||||||
|
filter =
|
||||||
|
CollectionFolderFilter(
|
||||||
|
nameOverride =
|
||||||
|
listOfNotNull(
|
||||||
|
name,
|
||||||
|
parentName,
|
||||||
|
).joinToString(" "),
|
||||||
|
filter =
|
||||||
|
GetItemsFilter(
|
||||||
|
studios = listOf(studioId),
|
||||||
|
includeItemTypes = includeItemTypes,
|
||||||
|
),
|
||||||
|
useSavedLibraryDisplayInfo = false,
|
||||||
|
),
|
||||||
|
recursive = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
val BaseItem.studioNames get() = data.studios?.mapNotNull { it.name }.orEmpty()
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ import org.jellyfin.sdk.model.api.ImageType
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a chapter within a video
|
||||||
|
*/
|
||||||
data class Chapter(
|
data class Chapter(
|
||||||
val name: String?,
|
val name: String?,
|
||||||
val position: Duration,
|
val position: Duration,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type of a Seerr/Discover object with mapping to the Jellyfin [BaseItemKind]
|
||||||
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
enum class SeerrItemType(
|
enum class SeerrItemType(
|
||||||
val baseItemKind: BaseItemKind?,
|
val baseItemKind: BaseItemKind?,
|
||||||
|
|
@ -46,6 +49,9 @@ enum class SeerrItemType(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How available is a particular discovered item within the Jellyfin server
|
||||||
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
enum class SeerrAvailability(
|
enum class SeerrAvailability(
|
||||||
val status: Int,
|
val status: Int,
|
||||||
|
|
@ -64,7 +70,7 @@ enum class SeerrAvailability(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An item provided by a discovery service (ie Seerr). It may exist on the JF server as well.
|
* An item provided by a discovery service (ie Seerr). It may exist on the JF server as well, see [availability].
|
||||||
*/
|
*/
|
||||||
@Stable
|
@Stable
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|
@ -104,6 +110,9 @@ data class DiscoverItem(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A rating for a discovered item which is usually fetched separately from the item
|
||||||
|
*/
|
||||||
data class DiscoverRating(
|
data class DiscoverRating(
|
||||||
val criticRating: Int?,
|
val criticRating: Int?,
|
||||||
val audienceRating: Float?,
|
val audienceRating: Float?,
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ import org.jellyfin.sdk.model.api.request.GetPersonsRequest
|
||||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter for a collection folder
|
||||||
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class CollectionFolderFilter(
|
data class CollectionFolderFilter(
|
||||||
val nameOverride: String? = null,
|
val nameOverride: String? = null,
|
||||||
|
|
@ -24,6 +27,9 @@ data class CollectionFolderFilter(
|
||||||
val useSavedLibraryDisplayInfo: Boolean = true,
|
val useSavedLibraryDisplayInfo: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sort of simplified filter which can be [applyTo] a [GetItemsRequest] or [GetPersonsRequest] to add or remove filters
|
||||||
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class GetItemsFilter(
|
data class GetItemsFilter(
|
||||||
val favorite: Boolean? = null,
|
val favorite: Boolean? = null,
|
||||||
|
|
@ -52,7 +58,7 @@ data class GetItemsFilter(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all of the values for the given filters
|
* Clear all the values for the given filters
|
||||||
*/
|
*/
|
||||||
fun delete(filterOptions: List<ItemFilterBy<*>>): GetItemsFilter {
|
fun delete(filterOptions: List<ItemFilterBy<*>>): GetItemsFilter {
|
||||||
var newFilter = this
|
var newFilter = this
|
||||||
|
|
@ -128,6 +134,9 @@ data class GetItemsFilter(
|
||||||
isFavorite = favorite,
|
isFavorite = favorite,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge another [GetItemsFilter] onto this one, replacing only unset values
|
||||||
|
*/
|
||||||
fun merge(filter: GetItemsFilter): GetItemsFilter =
|
fun merge(filter: GetItemsFilter): GetItemsFilter =
|
||||||
this.copy(
|
this.copy(
|
||||||
favorite = favorite ?: filter.favorite,
|
favorite = favorite ?: filter.favorite,
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,18 @@ sealed interface HomeRowConfig {
|
||||||
override fun updateViewOptions(viewOptions: HomeRowViewOptions): Genres = this.copy(viewOptions = viewOptions)
|
override fun updateViewOptions(viewOptions: HomeRowViewOptions): Genres = this.copy(viewOptions = viewOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Row of a studios in a library
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("Studios")
|
||||||
|
data class Studios(
|
||||||
|
val parentId: UUID,
|
||||||
|
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions.genreDefault,
|
||||||
|
) : HomeRowConfig {
|
||||||
|
override fun updateViewOptions(viewOptions: HomeRowViewOptions): Studios = this.copy(viewOptions = viewOptions)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Favorites for a specific type
|
* Favorites for a specific type
|
||||||
*/
|
*/
|
||||||
|
|
@ -162,7 +174,7 @@ sealed interface HomeRowConfig {
|
||||||
@SerialName("ByParent")
|
@SerialName("ByParent")
|
||||||
data class ByParent(
|
data class ByParent(
|
||||||
val parentId: UUID,
|
val parentId: UUID,
|
||||||
val recursive: Boolean,
|
val recursive: Boolean = false,
|
||||||
val sort: SortAndDirection? = null,
|
val sort: SortAndDirection? = null,
|
||||||
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
|
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
|
||||||
) : HomeRowConfig {
|
) : HomeRowConfig {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,10 @@ import kotlinx.serialization.UseSerializers
|
||||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store the media source and audio/subtitle tracks chosen for a specific media item
|
||||||
|
*
|
||||||
|
*/
|
||||||
@Entity(
|
@Entity(
|
||||||
foreignKeys = [
|
foreignKeys = [
|
||||||
ForeignKey(
|
ForeignKey(
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,11 @@ import kotlinx.serialization.UseSerializers
|
||||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store modifications to an audio/subtitle track in a media item
|
||||||
|
*
|
||||||
|
* For example, the subtitle delay
|
||||||
|
*/
|
||||||
@Entity(
|
@Entity(
|
||||||
foreignKeys = [
|
foreignKeys = [
|
||||||
ForeignKey(
|
ForeignKey(
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ import org.jellyfin.sdk.model.ServerVersion
|
||||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a Jellyfin server
|
||||||
|
*/
|
||||||
@Entity(tableName = "servers")
|
@Entity(tableName = "servers")
|
||||||
@Serializable
|
@Serializable
|
||||||
data class JellyfinServer(
|
data class JellyfinServer(
|
||||||
|
|
@ -29,6 +32,9 @@ data class JellyfinServer(
|
||||||
val serverVersion: ServerVersion? by lazy { version?.let(ServerVersion::fromString) }
|
val serverVersion: ServerVersion? by lazy { version?.let(ServerVersion::fromString) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a Jellyfin user for a particular server
|
||||||
|
*/
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "users",
|
tableName = "users",
|
||||||
foreignKeys = [
|
foreignKeys = [
|
||||||
|
|
@ -59,6 +65,9 @@ data class JellyfinUser(
|
||||||
"JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()})"
|
"JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()})"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the relationship between [JellyfinServer] and its [JellyfinUser]
|
||||||
|
*/
|
||||||
data class JellyfinServerUsers(
|
data class JellyfinServerUsers(
|
||||||
@Embedded val server: JellyfinServer,
|
@Embedded val server: JellyfinServer,
|
||||||
@Relation(
|
@Relation(
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,20 @@ import androidx.room.Ignore
|
||||||
import androidx.room.Index
|
import androidx.room.Index
|
||||||
import com.github.damontecres.wholphin.ui.components.ViewOptions
|
import com.github.damontecres.wholphin.ui.components.ViewOptions
|
||||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||||
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.Transient
|
import kotlinx.serialization.Transient
|
||||||
import kotlinx.serialization.UseSerializers
|
import kotlinx.serialization.UseSerializers
|
||||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
import org.jellyfin.sdk.model.api.SortOrder
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the filter, sort, and view options a user changes for a library
|
||||||
|
*
|
||||||
|
* This allows for restoring these settings whenever the user navigates to the library
|
||||||
|
*/
|
||||||
@Entity(
|
@Entity(
|
||||||
foreignKeys = [
|
foreignKeys = [
|
||||||
ForeignKey(
|
ForeignKey(
|
||||||
|
|
@ -41,4 +48,13 @@ data class LibraryDisplayInfo(
|
||||||
) {
|
) {
|
||||||
@Ignore @Transient
|
@Ignore @Transient
|
||||||
val sortAndDirection = SortAndDirection(sort, direction)
|
val sortAndDirection = SortAndDirection(sort, direction)
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
user: JellyfinUser,
|
||||||
|
itemId: UUID,
|
||||||
|
sort: ItemSortBy,
|
||||||
|
direction: SortOrder,
|
||||||
|
filter: GetItemsFilter,
|
||||||
|
viewOptions: ViewOptions?,
|
||||||
|
) : this(user.rowId, itemId.toServerString(), sort, direction, filter, viewOptions)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ enum class NavPinType {
|
||||||
UNPINNED,
|
UNPINNED,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores preference information about nav drawer items such as its order and whether to show or put in More
|
||||||
|
*/
|
||||||
@Entity(
|
@Entity(
|
||||||
foreignKeys = [
|
foreignKeys = [
|
||||||
ForeignKey(
|
ForeignKey(
|
||||||
|
|
@ -12,6 +12,9 @@ import org.jellyfin.sdk.model.api.BaseItemPerson
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
import org.jellyfin.sdk.model.api.PersonKind
|
import org.jellyfin.sdk.model.api.PersonKind
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a person in some media such as an actor or director
|
||||||
|
*/
|
||||||
@Stable
|
@Stable
|
||||||
data class Person(
|
data class Person(
|
||||||
val id: UUID,
|
val id: UUID,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@ import androidx.room.Entity
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store effects applied to some media such as color or hue adjustments
|
||||||
|
*/
|
||||||
@Entity(tableName = "playback_effects", primaryKeys = ["jellyfinUserRowId", "itemId", "type"])
|
@Entity(tableName = "playback_effects", primaryKeys = ["jellyfinUserRowId", "itemId", "type"])
|
||||||
data class PlaybackEffect(
|
data class PlaybackEffect(
|
||||||
val jellyfinUserRowId: Int,
|
val jellyfinUserRowId: Int,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,10 @@ import kotlinx.serialization.UseSerializers
|
||||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the language choices for a series so they can be applied automatically to other episodes
|
||||||
|
* without the user needing to explicitly choose the tracks
|
||||||
|
*/
|
||||||
@Entity(
|
@Entity(
|
||||||
foreignKeys = [
|
foreignKeys = [
|
||||||
ForeignKey(
|
ForeignKey(
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,11 @@ import androidx.compose.runtime.setValue
|
||||||
import org.jellyfin.sdk.model.api.MediaType
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks playback of multiple items. Points to the current media with function to advance or go to previous ones.
|
||||||
|
*
|
||||||
|
* This is not the same thing as a Jellyfin server playlist
|
||||||
|
*/
|
||||||
class Playlist(
|
class Playlist(
|
||||||
items: List<BaseItem>,
|
items: List<BaseItem>,
|
||||||
startIndex: Int = 0,
|
startIndex: Int = 0,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ package com.github.damontecres.wholphin.data.model
|
||||||
|
|
||||||
import com.github.damontecres.wholphin.services.SeerrUserConfig
|
import com.github.damontecres.wholphin.services.SeerrUserConfig
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permission levels for a user on a Seerr server
|
||||||
|
*/
|
||||||
enum class SeerrPermission(
|
enum class SeerrPermission(
|
||||||
private val flag: Int,
|
private val flag: Int,
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,9 @@ import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.UseSerializers
|
import kotlinx.serialization.UseSerializers
|
||||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a Seerr server instance
|
||||||
|
*/
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "seerr_servers",
|
tableName = "seerr_servers",
|
||||||
indices = [Index("url", unique = true)],
|
indices = [Index("url", unique = true)],
|
||||||
|
|
@ -26,6 +29,9 @@ data class SeerrServer(
|
||||||
val version: String? = null,
|
val version: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a user on a [SeerrServer]
|
||||||
|
*/
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "seerr_users",
|
tableName = "seerr_users",
|
||||||
foreignKeys = [
|
foreignKeys = [
|
||||||
|
|
@ -56,12 +62,18 @@ data class SeerrUser(
|
||||||
"SeerrUser(jellyfinUserRowId=$jellyfinUserRowId, serverId=$serverId, authMethod=$authMethod, username=$username, password?=${password.isNotNullOrBlank()}, credential?=${credential.isNotNullOrBlank()})"
|
"SeerrUser(jellyfinUserRowId=$jellyfinUserRowId, serverId=$serverId, authMethod=$authMethod, username=$username, password?=${password.isNotNullOrBlank()}, credential?=${credential.isNotNullOrBlank()})"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The method used to authenticate a user to the server
|
||||||
|
*/
|
||||||
enum class SeerrAuthMethod {
|
enum class SeerrAuthMethod {
|
||||||
LOCAL,
|
LOCAL,
|
||||||
JELLYFIN,
|
JELLYFIN,
|
||||||
API_KEY,
|
API_KEY,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the relationship between a [SeerrServer] and its [SeerrUser]s
|
||||||
|
*/
|
||||||
data class SeerrServerUsers(
|
data class SeerrServerUsers(
|
||||||
@Embedded val server: SeerrServer,
|
@Embedded val server: SeerrServer,
|
||||||
@Relation(
|
@Relation(
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,15 @@
|
||||||
package com.github.damontecres.wholphin.data.model
|
package com.github.damontecres.wholphin.data.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a trailer for media
|
||||||
|
*/
|
||||||
sealed interface Trailer {
|
sealed interface Trailer {
|
||||||
val name: String
|
val name: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [Trailer] stored on the Jellyfin server
|
||||||
|
*/
|
||||||
data class LocalTrailer(
|
data class LocalTrailer(
|
||||||
val baseItem: BaseItem,
|
val baseItem: BaseItem,
|
||||||
) : Trailer {
|
) : Trailer {
|
||||||
|
|
@ -11,6 +17,9 @@ data class LocalTrailer(
|
||||||
get() = baseItem.name ?: ""
|
get() = baseItem.name ?: ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [Trailer] available via a remote URL, such as YouTube
|
||||||
|
*/
|
||||||
data class RemoteTrailer(
|
data class RemoteTrailer(
|
||||||
override val name: String,
|
override val name: String,
|
||||||
val url: String,
|
val url: String,
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import androidx.media3.effect.ScaleAndRotateTransformation
|
||||||
import androidx.room.Ignore
|
import androidx.room.Ignore
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Modifications to a video playback
|
* Modifications to an image or video playback
|
||||||
*/
|
*/
|
||||||
data class VideoFilter(
|
data class VideoFilter(
|
||||||
val rotation: Int = 0,
|
val rotation: Int = 0,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import androidx.annotation.ArrayRes
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
|
import com.github.damontecres.wholphin.BuildConfig
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.WholphinApplication
|
import com.github.damontecres.wholphin.WholphinApplication
|
||||||
import com.github.damontecres.wholphin.services.UpdateChecker
|
import com.github.damontecres.wholphin.services.UpdateChecker
|
||||||
|
|
@ -62,7 +63,7 @@ sealed interface AppPreference<Pref, T> {
|
||||||
AppSliderPreference<AppPreferences>(
|
AppSliderPreference<AppPreferences>(
|
||||||
title = R.string.skip_forward_preference,
|
title = R.string.skip_forward_preference,
|
||||||
defaultValue = 30,
|
defaultValue = 30,
|
||||||
min = 10,
|
min = 5,
|
||||||
max = 5.minutes.inWholeSeconds,
|
max = 5.minutes.inWholeSeconds,
|
||||||
interval = 5,
|
interval = 5,
|
||||||
getter = {
|
getter = {
|
||||||
|
|
@ -464,16 +465,18 @@ sealed interface AppPreference<Pref, T> {
|
||||||
summaryOn = R.string.enabled,
|
summaryOn = R.string.enabled,
|
||||||
summaryOff = R.string.disabled,
|
summaryOff = R.string.disabled,
|
||||||
)
|
)
|
||||||
val DirectPlayAss =
|
val AssSubtitleMode =
|
||||||
AppSwitchPreference<AppPreferences>(
|
AppChoicePreference<AppPreferences, AssPlaybackMode>(
|
||||||
title = R.string.direct_play_ass,
|
title = R.string.ass_subtitle_playback,
|
||||||
defaultValue = true,
|
defaultValue = AssPlaybackMode.ASS_LIBASS,
|
||||||
getter = { it.playbackPreferences.overrides.directPlayAss },
|
getter = { it.playbackPreferences.overrides.assPlaybackMode },
|
||||||
setter = { prefs, value ->
|
setter = { prefs, value ->
|
||||||
prefs.updatePlaybackOverrides { directPlayAss = value }
|
prefs.updatePlaybackOverrides { assPlaybackMode = value }
|
||||||
},
|
},
|
||||||
summaryOn = R.string.enabled,
|
displayValues = R.array.ass_subtitle_modes,
|
||||||
summaryOff = R.string.disabled,
|
subtitles = R.array.ass_subtitle_modes_summary,
|
||||||
|
indexToValue = { AssPlaybackMode.forNumber(it) },
|
||||||
|
valueToIndex = { if (it != AssPlaybackMode.UNRECOGNIZED) it.number else AssPlaybackMode.ASS_LIBASS.number },
|
||||||
)
|
)
|
||||||
val DirectPlayPgs =
|
val DirectPlayPgs =
|
||||||
AppSwitchPreference<AppPreferences>(
|
AppSwitchPreference<AppPreferences>(
|
||||||
|
|
@ -535,6 +538,18 @@ sealed interface AppPreference<Pref, T> {
|
||||||
valueToIndex = { if (it != AppThemeColors.UNRECOGNIZED) it.number else 0 },
|
valueToIndex = { if (it != AppThemeColors.UNRECOGNIZED) it.number else 0 },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val ShowLogos =
|
||||||
|
AppSwitchPreference<AppPreferences>(
|
||||||
|
title = R.string.prefer_logos,
|
||||||
|
defaultValue = true,
|
||||||
|
getter = { it.interfacePreferences.showLogos },
|
||||||
|
setter = { prefs, value ->
|
||||||
|
prefs.updateInterfacePreferences { showLogos = value }
|
||||||
|
},
|
||||||
|
summaryOn = R.string.enabled,
|
||||||
|
summaryOff = R.string.disabled,
|
||||||
|
)
|
||||||
|
|
||||||
val InstalledVersion =
|
val InstalledVersion =
|
||||||
AppClickablePreference<AppPreferences>(
|
AppClickablePreference<AppPreferences>(
|
||||||
title = R.string.installed_version,
|
title = R.string.installed_version,
|
||||||
|
|
@ -845,8 +860,19 @@ sealed interface AppPreference<Pref, T> {
|
||||||
},
|
},
|
||||||
displayValues = R.array.player_backend_options,
|
displayValues = R.array.player_backend_options,
|
||||||
subtitles = R.array.player_backend_options_subtitles,
|
subtitles = R.array.player_backend_options_subtitles,
|
||||||
indexToValue = { PlayerBackend.forNumber(it) },
|
indexToValue = { PlayerBackend.forNumber(it) ?: PlayerBackend.EXO_PLAYER },
|
||||||
valueToIndex = { it.number },
|
valueToIndex = { if (it != PlayerBackend.UNRECOGNIZED) it.number else PlayerBackend.EXO_PLAYER.number },
|
||||||
|
)
|
||||||
|
|
||||||
|
val ExternalPlayerApp =
|
||||||
|
AppStringPreference<AppPreferences>(
|
||||||
|
title = R.string.external_player,
|
||||||
|
defaultValue = "",
|
||||||
|
getter = { it.playbackPreferences.externalPlayer },
|
||||||
|
setter = { prefs, value ->
|
||||||
|
prefs.updatePlaybackPreferences { externalPlayer = value }
|
||||||
|
},
|
||||||
|
summary = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
val ExoPlayerSettings =
|
val ExoPlayerSettings =
|
||||||
|
|
@ -1107,10 +1133,12 @@ val basicPreferences =
|
||||||
PreferenceGroup(
|
PreferenceGroup(
|
||||||
title = R.string.more,
|
title = R.string.more,
|
||||||
preferences =
|
preferences =
|
||||||
listOf(
|
buildList {
|
||||||
AppPreference.SeerrIntegration,
|
if (BuildConfig.DISCOVER_ENABLED) {
|
||||||
AppPreference.AdvancedSettings,
|
add(AppPreference.SeerrIntegration)
|
||||||
),
|
}
|
||||||
|
add(AppPreference.AdvancedSettings)
|
||||||
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1120,7 +1148,7 @@ private val ExoPlayerSettings =
|
||||||
AppPreference.DownMixStereo,
|
AppPreference.DownMixStereo,
|
||||||
AppPreference.Ac3Supported,
|
AppPreference.Ac3Supported,
|
||||||
AppPreference.PreferAc3ForSurround,
|
AppPreference.PreferAc3ForSurround,
|
||||||
AppPreference.DirectPlayAss,
|
AppPreference.AssSubtitleMode,
|
||||||
AppPreference.DirectPlayPgs,
|
AppPreference.DirectPlayPgs,
|
||||||
AppPreference.DirectPlayDoviProfile7,
|
AppPreference.DirectPlayDoviProfile7,
|
||||||
AppPreference.DecodeAv1,
|
AppPreference.DecodeAv1,
|
||||||
|
|
@ -1157,6 +1185,7 @@ val advancedPreferences =
|
||||||
preferences =
|
preferences =
|
||||||
listOf(
|
listOf(
|
||||||
AppPreference.ShowClock,
|
AppPreference.ShowClock,
|
||||||
|
AppPreference.ShowLogos,
|
||||||
AppPreference.ManageMedia,
|
AppPreference.ManageMedia,
|
||||||
AppPreference.CombineContinueNext,
|
AppPreference.CombineContinueNext,
|
||||||
// Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418
|
// Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418
|
||||||
|
|
@ -1216,6 +1245,10 @@ val advancedPreferences =
|
||||||
AppPreference.MpvSettings,
|
AppPreference.MpvSettings,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
ConditionalPreferences(
|
||||||
|
{ it.playbackPreferences.playerBackend == PlayerBackend.EXTERNAL_PLAYER },
|
||||||
|
listOf(AppPreference.ExternalPlayerApp),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -61,10 +61,11 @@ class AppPreferencesSerializer
|
||||||
.apply {
|
.apply {
|
||||||
ac3Supported = AppPreference.Ac3Supported.defaultValue
|
ac3Supported = AppPreference.Ac3Supported.defaultValue
|
||||||
downmixStereo = AppPreference.DownMixStereo.defaultValue
|
downmixStereo = AppPreference.DownMixStereo.defaultValue
|
||||||
directPlayAss = AppPreference.DirectPlayAss.defaultValue
|
// directPlayAss = AppPreference.DirectPlayAss.defaultValue
|
||||||
directPlayPgs = AppPreference.DirectPlayPgs.defaultValue
|
directPlayPgs = AppPreference.DirectPlayPgs.defaultValue
|
||||||
mediaExtensionsEnabled =
|
mediaExtensionsEnabled =
|
||||||
AppPreference.FfmpegPreference.defaultValue
|
AppPreference.FfmpegPreference.defaultValue
|
||||||
|
assPlaybackMode = AppPreference.AssSubtitleMode.defaultValue
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
mpvOptions =
|
mpvOptions =
|
||||||
|
|
@ -95,6 +96,7 @@ class AppPreferencesSerializer
|
||||||
AppPreference.NavDrawerSwitchOnFocus.defaultValue
|
AppPreference.NavDrawerSwitchOnFocus.defaultValue
|
||||||
showClock = AppPreference.ShowClock.defaultValue
|
showClock = AppPreference.ShowClock.defaultValue
|
||||||
backdropStyle = AppPreference.BackdropStylePref.defaultValue
|
backdropStyle = AppPreference.BackdropStylePref.defaultValue
|
||||||
|
showLogos = AppPreference.ShowLogos.defaultValue
|
||||||
|
|
||||||
subtitlesPreferences =
|
subtitlesPreferences =
|
||||||
SubtitlePreferences
|
SubtitlePreferences
|
||||||
|
|
@ -152,6 +154,15 @@ class AppPreferencesSerializer
|
||||||
slideshowDuration = AppPreference.SlideshowDuration.defaultValue
|
slideshowDuration = AppPreference.SlideshowDuration.defaultValue
|
||||||
slideshowPlayVideos = AppPreference.SlideshowPlayVideos.defaultValue
|
slideshowPlayVideos = AppPreference.SlideshowPlayVideos.defaultValue
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
|
musicPreferences =
|
||||||
|
MusicPreferences
|
||||||
|
.newBuilder()
|
||||||
|
.apply {
|
||||||
|
showBackdrop = true
|
||||||
|
showLyrics = true
|
||||||
|
showAlbumArt = true
|
||||||
|
}.build()
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
override suspend fun readFrom(input: InputStream): AppPreferences {
|
override suspend fun readFrom(input: InputStream): AppPreferences {
|
||||||
|
|
@ -220,6 +231,11 @@ inline fun AppPreferences.updateScreensaverPreferences(block: ScreensaverPrefere
|
||||||
screensaverPreference = screensaverPreference.toBuilder().apply(block).build()
|
screensaverPreference = screensaverPreference.toBuilder().apply(block).build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inline fun AppPreferences.updateMusicPreferences(block: MusicPreferences.Builder.() -> Unit): AppPreferences =
|
||||||
|
update {
|
||||||
|
musicPreferences = musicPreferences.toBuilder().apply(block).build()
|
||||||
|
}
|
||||||
|
|
||||||
fun SubtitlePreferences.Builder.resetSubtitles() {
|
fun SubtitlePreferences.Builder.resetSubtitles() {
|
||||||
fontSize = SubtitleSettings.FontSize.defaultValue.toInt()
|
fontSize = SubtitleSettings.FontSize.defaultValue.toInt()
|
||||||
fontColor = SubtitleSettings.FontColor.defaultValue.toArgb()
|
fontColor = SubtitleSettings.FontColor.defaultValue.toArgb()
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
package com.github.damontecres.wholphin.services
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageInfo
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import com.github.damontecres.wholphin.WholphinApplication
|
|
||||||
import com.github.damontecres.wholphin.data.SeerrServerDao
|
import com.github.damontecres.wholphin.data.SeerrServerDao
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
|
@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.preferences.updateHomePagePreferences
|
||||||
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
|
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
|
||||||
import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences
|
import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.updateMpvOptions
|
import com.github.damontecres.wholphin.preferences.updateMpvOptions
|
||||||
|
import com.github.damontecres.wholphin.preferences.updateMusicPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.updatePhotoPreferences
|
import com.github.damontecres.wholphin.preferences.updatePhotoPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
|
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
|
||||||
import com.github.damontecres.wholphin.preferences.updateScreensaverPreferences
|
import com.github.damontecres.wholphin.preferences.updateScreensaverPreferences
|
||||||
|
|
@ -31,6 +32,9 @@ import java.io.File
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles any changes needed when the app in upgraded on the device such as setting new preferences
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class AppUpgradeHandler
|
class AppUpgradeHandler
|
||||||
@Inject
|
@Inject
|
||||||
|
|
@ -39,12 +43,10 @@ class AppUpgradeHandler
|
||||||
private val appPreferences: DataStore<AppPreferences>,
|
private val appPreferences: DataStore<AppPreferences>,
|
||||||
private val seerrServerDao: SeerrServerDao,
|
private val seerrServerDao: SeerrServerDao,
|
||||||
) {
|
) {
|
||||||
suspend fun run() {
|
val pkgInfo: PackageInfo get() = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||||
val pkgInfo =
|
val currentVersion: Version get() = Version.fromString(pkgInfo.versionName!!)
|
||||||
WholphinApplication.instance.packageManager.getPackageInfo(
|
|
||||||
WholphinApplication.instance.packageName,
|
fun needUpgrade(): Boolean {
|
||||||
0,
|
|
||||||
)
|
|
||||||
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
val previousVersion = prefs.getString(VERSION_NAME_CURRENT_KEY, null)
|
val previousVersion = prefs.getString(VERSION_NAME_CURRENT_KEY, null)
|
||||||
val previousVersionCode = prefs.getLong(VERSION_CODE_CURRENT_KEY, -1)
|
val previousVersionCode = prefs.getLong(VERSION_CODE_CURRENT_KEY, -1)
|
||||||
|
|
@ -56,32 +58,48 @@ class AppUpgradeHandler
|
||||||
} else {
|
} else {
|
||||||
pkgInfo.versionCode.toLong()
|
pkgInfo.versionCode.toLong()
|
||||||
}
|
}
|
||||||
if (newVersion != previousVersion || newVersionCode != previousVersionCode) {
|
Timber.i(
|
||||||
Timber.i(
|
"App versions: $previousVersion=>$newVersion, $previousVersionCode=>$newVersionCode",
|
||||||
"App updated: $previousVersion=>$newVersion, $previousVersionCode=>$newVersionCode",
|
)
|
||||||
)
|
return newVersion != previousVersion || newVersionCode != previousVersionCode
|
||||||
prefs.edit(true) {
|
|
||||||
putString(VERSION_NAME_PREVIOUS_KEY, previousVersion)
|
|
||||||
putLong(VERSION_CODE_PREVIOUS_KEY, previousVersionCode)
|
|
||||||
putString(VERSION_NAME_CURRENT_KEY, newVersion)
|
|
||||||
putLong(VERSION_CODE_CURRENT_KEY, newVersionCode)
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
copySubfont(true)
|
|
||||||
upgradeApp(
|
|
||||||
Version.fromString(previousVersion ?: "0.0.0"),
|
|
||||||
Version.fromString(newVersion),
|
|
||||||
appPreferences,
|
|
||||||
)
|
|
||||||
} catch (ex: Exception) {
|
|
||||||
Timber.e(ex, "Exception during app upgrade")
|
|
||||||
}
|
|
||||||
Timber.i("App upgrade complete")
|
|
||||||
} else {
|
|
||||||
Timber.d("No app update needed")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun run() {
|
||||||
|
Timber.i("App upgrade started")
|
||||||
|
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
val previousVersion = prefs.getString(VERSION_NAME_CURRENT_KEY, null)
|
||||||
|
val previousVersionCode = prefs.getLong(VERSION_CODE_CURRENT_KEY, -1)
|
||||||
|
|
||||||
|
val newVersion = pkgInfo.versionName!!
|
||||||
|
val newVersionCode =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
pkgInfo.longVersionCode
|
||||||
|
} else {
|
||||||
|
pkgInfo.versionCode.toLong()
|
||||||
|
}
|
||||||
|
// Store the previous and new version info
|
||||||
|
prefs.edit(true) {
|
||||||
|
putString(VERSION_NAME_PREVIOUS_KEY, previousVersion)
|
||||||
|
putLong(VERSION_CODE_PREVIOUS_KEY, previousVersionCode)
|
||||||
|
putString(VERSION_NAME_CURRENT_KEY, newVersion)
|
||||||
|
putLong(VERSION_CODE_CURRENT_KEY, newVersionCode)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
copySubfont(true)
|
||||||
|
upgradeApp(
|
||||||
|
Version.fromString(previousVersion ?: "0.0.0"),
|
||||||
|
Version.fromString(newVersion),
|
||||||
|
appPreferences,
|
||||||
|
)
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Exception during app upgrade")
|
||||||
|
}
|
||||||
|
Timber.i("App upgrade complete")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies the font file used by MPV subtitles to the app's files directory
|
||||||
|
*/
|
||||||
fun copySubfont(overwrite: Boolean) {
|
fun copySubfont(overwrite: Boolean) {
|
||||||
try {
|
try {
|
||||||
val fontFileName = "subfont.ttf"
|
val fontFileName = "subfont.ttf"
|
||||||
|
|
@ -110,6 +128,9 @@ class AppUpgradeHandler
|
||||||
const val VERSION_CODE_CURRENT_KEY = "version.current.code"
|
const val VERSION_CODE_CURRENT_KEY = "version.current.code"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform any needed upgrades
|
||||||
|
*/
|
||||||
suspend fun upgradeApp(
|
suspend fun upgradeApp(
|
||||||
previous: Version,
|
previous: Version,
|
||||||
current: Version,
|
current: Version,
|
||||||
|
|
@ -120,7 +141,7 @@ class AppUpgradeHandler
|
||||||
it.updatePlaybackOverrides {
|
it.updatePlaybackOverrides {
|
||||||
ac3Supported = AppPreference.Ac3Supported.defaultValue
|
ac3Supported = AppPreference.Ac3Supported.defaultValue
|
||||||
downmixStereo = AppPreference.DownMixStereo.defaultValue
|
downmixStereo = AppPreference.DownMixStereo.defaultValue
|
||||||
directPlayAss = AppPreference.DirectPlayAss.defaultValue
|
// directPlayAss = AppPreference.DirectPlayAss.defaultValue
|
||||||
directPlayPgs = AppPreference.DirectPlayPgs.defaultValue
|
directPlayPgs = AppPreference.DirectPlayPgs.defaultValue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -280,5 +301,31 @@ class AppUpgradeHandler
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (previous.isEqualOrBefore(Version.fromString("0.5.4-6-g0"))) {
|
||||||
|
appPreferences.updateData {
|
||||||
|
it.updateMusicPreferences {
|
||||||
|
showBackdrop = true
|
||||||
|
showLyrics = true
|
||||||
|
showAlbumArt = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previous.isEqualOrBefore(Version.fromString("0.5.4-15-g0"))) {
|
||||||
|
appPreferences.updateData {
|
||||||
|
it.updateInterfacePreferences {
|
||||||
|
showLogos = AppPreference.ShowLogos.defaultValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previous.isEqualOrBefore(Version.fromString("0.6.2-1-g0"))) {
|
||||||
|
appPreferences.updateData {
|
||||||
|
it.updatePlaybackOverrides {
|
||||||
|
assPlaybackMode = AppPreference.AssSubtitleMode.defaultValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,11 @@ import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores state for the backdrop of the app shown on non-full screen pages
|
||||||
|
*
|
||||||
|
* This is usually the backdrop of the currently focused media
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
@OptIn(FlowPreview::class)
|
@OptIn(FlowPreview::class)
|
||||||
class BackdropService
|
class BackdropService
|
||||||
|
|
@ -46,6 +51,9 @@ class BackdropService
|
||||||
private val _backdropFlow = MutableStateFlow<BackdropResult>(BackdropResult.NONE)
|
private val _backdropFlow = MutableStateFlow<BackdropResult>(BackdropResult.NONE)
|
||||||
val backdropFlow = _backdropFlow
|
val backdropFlow = _backdropFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the backdrop to use the specified item
|
||||||
|
*/
|
||||||
suspend fun submit(item: BaseItem) =
|
suspend fun submit(item: BaseItem) =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val imageUrl =
|
val imageUrl =
|
||||||
|
|
@ -57,8 +65,14 @@ class BackdropService
|
||||||
submit(item.id.toString(), imageUrl)
|
submit(item.id.toString(), imageUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the backdrop to use the specified discovered item
|
||||||
|
*/
|
||||||
suspend fun submit(item: DiscoverItem) = submit("discover_${item.id}", item.backDropUrl)
|
suspend fun submit(item: DiscoverItem) = submit("discover_${item.id}", item.backDropUrl)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the backdrop to use the specified URL
|
||||||
|
*/
|
||||||
suspend fun submit(
|
suspend fun submit(
|
||||||
itemId: String,
|
itemId: String,
|
||||||
imageUrl: String?,
|
imageUrl: String?,
|
||||||
|
|
@ -74,6 +88,9 @@ class BackdropService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the backdrop, such as when switching pages
|
||||||
|
*/
|
||||||
suspend fun clearBackdrop() {
|
suspend fun clearBackdrop() {
|
||||||
_backdropFlow.update {
|
_backdropFlow.update {
|
||||||
BackdropResult.NONE
|
BackdropResult.NONE
|
||||||
|
|
@ -114,7 +131,7 @@ class BackdropService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun extractColorsFromBackdrop(imageUrl: String?): ExtractedColors =
|
suspend fun extractColorsFromBackdrop(imageUrl: String?): ExtractedColors =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
if (imageUrl.isNullOrBlank()) {
|
if (imageUrl.isNullOrBlank()) {
|
||||||
return@withContext ExtractedColors.DEFAULT
|
return@withContext ExtractedColors.DEFAULT
|
||||||
|
|
@ -224,6 +241,9 @@ class BackdropService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The result from determining the backdrop URL and extracted colors for the dynamic backdrop
|
||||||
|
*/
|
||||||
data class BackdropResult(
|
data class BackdropResult(
|
||||||
val itemId: String?,
|
val itemId: String?,
|
||||||
val imageUrl: String?,
|
val imageUrl: String?,
|
||||||
|
|
@ -245,6 +265,9 @@ data class BackdropResult(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The colors extracted from an image
|
||||||
|
*/
|
||||||
data class ExtractedColors(
|
data class ExtractedColors(
|
||||||
val primary: Color,
|
val primary: Color,
|
||||||
val secondary: Color,
|
val secondary: Color,
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,9 @@ import java.util.concurrent.TimeUnit
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caches when media was lasted played. This is mostly used by the combined Continue Watching & Next Up home row.
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class DatePlayedService
|
class DatePlayedService
|
||||||
@Inject
|
@Inject
|
||||||
|
|
@ -80,6 +83,12 @@ class DatePlayedService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the logical timestamp when the item was last played.
|
||||||
|
*
|
||||||
|
* This is calculated using lastest of the actual last played timestamp of the item,
|
||||||
|
* the previous episode's last played timestamp, and the episode's premiere date
|
||||||
|
*/
|
||||||
suspend fun getLastPlayed(item: BaseItem): LocalDateTime? =
|
suspend fun getLastPlayed(item: BaseItem): LocalDateTime? =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val seriesId = item.data.seriesId
|
val seriesId = item.data.seriesId
|
||||||
|
|
@ -93,6 +102,11 @@ class DatePlayedService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the cached last date played for the given item's series
|
||||||
|
*
|
||||||
|
* Used for when a user plays this item
|
||||||
|
*/
|
||||||
fun invalidate(item: BaseItem) {
|
fun invalidate(item: BaseItem) {
|
||||||
item.data.seriesId?.let { seriesId ->
|
item.data.seriesId?.let { seriesId ->
|
||||||
Timber.d("Invalidating %s", seriesId)
|
Timber.d("Invalidating %s", seriesId)
|
||||||
|
|
@ -100,6 +114,9 @@ class DatePlayedService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the cached last data played for the given item or its series
|
||||||
|
*/
|
||||||
suspend fun invalidate(itemId: UUID) {
|
suspend fun invalidate(itemId: UUID) {
|
||||||
val seriesId =
|
val seriesId =
|
||||||
api.userLibraryApi.getItem(itemId = itemId).content.let {
|
api.userLibraryApi.getItem(itemId = itemId).content.let {
|
||||||
|
|
@ -121,6 +138,9 @@ class DatePlayedService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidates the date played cached when the current user changes
|
||||||
|
*/
|
||||||
@ActivityScoped
|
@ActivityScoped
|
||||||
class DatePlayedInvalidationService
|
class DatePlayedInvalidationService
|
||||||
@Inject
|
@Inject
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.github.damontecres.wholphin.services
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import com.github.damontecres.wholphin.preferences.AssPlaybackMode
|
||||||
import com.github.damontecres.wholphin.preferences.PlaybackPreferences
|
import com.github.damontecres.wholphin.preferences.PlaybackPreferences
|
||||||
import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest
|
import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest
|
||||||
import com.github.damontecres.wholphin.util.profile.createDeviceProfile
|
import com.github.damontecres.wholphin.util.profile.createDeviceProfile
|
||||||
|
|
@ -14,6 +15,9 @@ import org.jellyfin.sdk.model.api.DeviceProfile
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates and caches the device direct play/transcoding profile sent to the server for ExoPlayer
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class DeviceProfileService
|
class DeviceProfileService
|
||||||
@Inject
|
@Inject
|
||||||
|
|
@ -21,7 +25,7 @@ class DeviceProfileService
|
||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
) {
|
) {
|
||||||
val mediaCodecCapabilitiesTest by lazy {
|
val mediaCodecCapabilitiesTest by lazy {
|
||||||
// Created lazily below on the IO thread since it cn take time
|
// Created lazily below on another thread since it cn take time
|
||||||
MediaCodecCapabilitiesTest(context)
|
MediaCodecCapabilitiesTest(context)
|
||||||
}
|
}
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
@ -33,14 +37,14 @@ class DeviceProfileService
|
||||||
prefs: PlaybackPreferences,
|
prefs: PlaybackPreferences,
|
||||||
serverVersion: ServerVersion?,
|
serverVersion: ServerVersion?,
|
||||||
): DeviceProfile =
|
): DeviceProfile =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.Default) {
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
val newConfig =
|
val newConfig =
|
||||||
DeviceProfileConfiguration(
|
DeviceProfileConfiguration(
|
||||||
maxBitrate = prefs.maxBitrate.toInt(),
|
maxBitrate = prefs.maxBitrate.toInt(),
|
||||||
isAC3Enabled = prefs.overrides.ac3Supported,
|
isAC3Enabled = prefs.overrides.ac3Supported,
|
||||||
downMixAudio = prefs.overrides.downmixStereo,
|
downMixAudio = prefs.overrides.downmixStereo,
|
||||||
assDirectPlay = prefs.overrides.directPlayAss,
|
assPlaybackMode = prefs.overrides.assPlaybackMode,
|
||||||
pgsDirectPlay = prefs.overrides.directPlayPgs,
|
pgsDirectPlay = prefs.overrides.directPlayPgs,
|
||||||
dolbyVisionELDirectPlay = prefs.overrides.directPlayDolbyVisionEL,
|
dolbyVisionELDirectPlay = prefs.overrides.directPlayDolbyVisionEL,
|
||||||
decodeAv1 = prefs.overrides.decodeAv1,
|
decodeAv1 = prefs.overrides.decodeAv1,
|
||||||
|
|
@ -56,7 +60,7 @@ class DeviceProfileService
|
||||||
maxBitrate = newConfig.maxBitrate,
|
maxBitrate = newConfig.maxBitrate,
|
||||||
isAC3Enabled = newConfig.isAC3Enabled,
|
isAC3Enabled = newConfig.isAC3Enabled,
|
||||||
downMixAudio = newConfig.downMixAudio,
|
downMixAudio = newConfig.downMixAudio,
|
||||||
assDirectPlay = newConfig.assDirectPlay,
|
assDirectPlay = newConfig.assPlaybackMode != AssPlaybackMode.ASS_TRANSCODE,
|
||||||
pgsDirectPlay = newConfig.pgsDirectPlay,
|
pgsDirectPlay = newConfig.pgsDirectPlay,
|
||||||
dolbyVisionELDirectPlay = newConfig.dolbyVisionELDirectPlay,
|
dolbyVisionELDirectPlay = newConfig.dolbyVisionELDirectPlay,
|
||||||
decodeAv1 = prefs.overrides.decodeAv1,
|
decodeAv1 = prefs.overrides.decodeAv1,
|
||||||
|
|
@ -76,7 +80,7 @@ data class DeviceProfileConfiguration(
|
||||||
val maxBitrate: Int,
|
val maxBitrate: Int,
|
||||||
val isAC3Enabled: Boolean,
|
val isAC3Enabled: Boolean,
|
||||||
val downMixAudio: Boolean,
|
val downMixAudio: Boolean,
|
||||||
val assDirectPlay: Boolean,
|
val assPlaybackMode: AssPlaybackMode,
|
||||||
val pgsDirectPlay: Boolean,
|
val pgsDirectPlay: Boolean,
|
||||||
val dolbyVisionELDirectPlay: Boolean,
|
val dolbyVisionELDirectPlay: Boolean,
|
||||||
val decodeAv1: Boolean,
|
val decodeAv1: Boolean,
|
||||||
|
|
|
||||||
|
|
@ -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.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get extras for media
|
||||||
|
*
|
||||||
|
* @see [ExtrasItem]
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class ExtrasService
|
class ExtrasService
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
private val api: ApiClient,
|
private val api: ApiClient,
|
||||||
) {
|
) {
|
||||||
|
/**
|
||||||
|
* Get the [ExtrasItem]s for the given item
|
||||||
|
*/
|
||||||
suspend fun getExtras(itemId: UUID): List<ExtrasItem> {
|
suspend fun getExtras(itemId: UUID): List<ExtrasItem> {
|
||||||
val extrasMap =
|
val extrasMap =
|
||||||
api.userLibraryApi
|
api.userLibraryApi
|
||||||
|
|
@ -42,6 +50,9 @@ class ExtrasService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The order which extras should be shown
|
||||||
|
*/
|
||||||
private val ExtraType.sortOrder: Int
|
private val ExtraType.sortOrder: Int
|
||||||
get() =
|
get() =
|
||||||
when (this) {
|
when (this) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.github.damontecres.wholphin.services
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
|
|
@ -8,6 +9,9 @@ import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles toggling media as favorited or watched
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class FavoriteWatchManager
|
class FavoriteWatchManager
|
||||||
@Inject
|
@Inject
|
||||||
|
|
@ -36,4 +40,7 @@ class FavoriteWatchManager
|
||||||
} else {
|
} else {
|
||||||
api.userLibraryApi.unmarkFavoriteItem(itemId).content
|
api.userLibraryApi.unmarkFavoriteItem(itemId).content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun removeContinueWatching(item: BaseItem) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import com.github.damontecres.wholphin.data.model.HomePageSettings
|
||||||
import com.github.damontecres.wholphin.data.model.HomeRowConfig
|
import com.github.damontecres.wholphin.data.model.HomeRowConfig
|
||||||
import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION
|
import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION
|
||||||
import com.github.damontecres.wholphin.data.model.createGenreDestination
|
import com.github.damontecres.wholphin.data.model.createGenreDestination
|
||||||
|
import com.github.damontecres.wholphin.data.model.createStudioDestination
|
||||||
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
|
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
|
||||||
import com.github.damontecres.wholphin.preferences.HomePagePreferences
|
import com.github.damontecres.wholphin.preferences.HomePagePreferences
|
||||||
import com.github.damontecres.wholphin.ui.DefaultItemFields
|
import com.github.damontecres.wholphin.ui.DefaultItemFields
|
||||||
|
|
@ -21,6 +22,7 @@ import com.github.damontecres.wholphin.ui.toServerString
|
||||||
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
|
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.GetPersonsHandler
|
import com.github.damontecres.wholphin.util.GetPersonsHandler
|
||||||
|
import com.github.damontecres.wholphin.util.GetStudiosRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState.Error
|
import com.github.damontecres.wholphin.util.HomeRowLoadingState.Error
|
||||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success
|
import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success
|
||||||
|
|
@ -40,7 +42,6 @@ import kotlinx.serialization.json.jsonArray
|
||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.displayPreferencesApi
|
|
||||||
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.userApi
|
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
|
|
@ -58,11 +59,15 @@ import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
|
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetRecommendedProgramsRequest
|
import org.jellyfin.sdk.model.api.request.GetRecommendedProgramsRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetRecordingsRequest
|
import org.jellyfin.sdk.model.api.request.GetRecordingsRequest
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetStudiosRequest
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles getting home page settings and data
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class HomeSettingsService
|
class HomeSettingsService
|
||||||
@Inject
|
@Inject
|
||||||
|
|
@ -75,6 +80,7 @@ class HomeSettingsService
|
||||||
private val latestNextUpService: LatestNextUpService,
|
private val latestNextUpService: LatestNextUpService,
|
||||||
private val imageUrlService: ImageUrlService,
|
private val imageUrlService: ImageUrlService,
|
||||||
private val suggestionService: SuggestionService,
|
private val suggestionService: SuggestionService,
|
||||||
|
private val displayPreferencesService: DisplayPreferencesService,
|
||||||
) {
|
) {
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
val jsonParser =
|
val jsonParser =
|
||||||
|
|
@ -84,6 +90,9 @@ class HomeSettingsService
|
||||||
allowTrailingComma = true
|
allowTrailingComma = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current home page settings
|
||||||
|
*/
|
||||||
val currentSettings = MutableStateFlow(HomePageResolvedSettings.EMPTY)
|
val currentSettings = MutableStateFlow(HomePageResolvedSettings.EMPTY)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -94,19 +103,11 @@ class HomeSettingsService
|
||||||
suspend fun saveToServer(
|
suspend fun saveToServer(
|
||||||
userId: UUID,
|
userId: UUID,
|
||||||
settings: HomePageSettings,
|
settings: HomePageSettings,
|
||||||
displayPreferencesId: String = DISPLAY_PREF_ID,
|
displayPreferencesId: String = DisplayPreferencesService.DEFAULT_DISPLAY_PREF_ID,
|
||||||
) {
|
) {
|
||||||
val current = getDisplayPreferences(userId, DISPLAY_PREF_ID)
|
displayPreferencesService.updateDisplayPreferences(userId, displayPreferencesId) {
|
||||||
val customPrefs =
|
put(CUSTOM_PREF_ID, jsonParser.encodeToString(settings))
|
||||||
current.customPrefs.toMutableMap().apply {
|
}
|
||||||
put(CUSTOM_PREF_ID, jsonParser.encodeToString(settings))
|
|
||||||
}
|
|
||||||
api.displayPreferencesApi.updateDisplayPreferences(
|
|
||||||
displayPreferencesId = displayPreferencesId,
|
|
||||||
userId = userId,
|
|
||||||
client = context.getString(R.string.app_name),
|
|
||||||
data = current.copy(customPrefs = customPrefs),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -118,24 +119,15 @@ class HomeSettingsService
|
||||||
*/
|
*/
|
||||||
suspend fun loadFromServer(
|
suspend fun loadFromServer(
|
||||||
userId: UUID,
|
userId: UUID,
|
||||||
displayPreferencesId: String = DISPLAY_PREF_ID,
|
displayPreferencesId: String = DisplayPreferencesService.DEFAULT_DISPLAY_PREF_ID,
|
||||||
): HomePageSettings? {
|
): HomePageSettings? =
|
||||||
val current = getDisplayPreferences(userId, displayPreferencesId)
|
displayPreferencesService
|
||||||
return current.customPrefs[CUSTOM_PREF_ID]?.let {
|
.getDisplayPreferences(userId, displayPreferencesId)
|
||||||
val jsonElement = jsonParser.parseToJsonElement(it)
|
.customPrefs[CUSTOM_PREF_ID]
|
||||||
decode(jsonElement)
|
?.let {
|
||||||
}
|
val jsonElement = jsonParser.parseToJsonElement(it)
|
||||||
}
|
decode(jsonElement)
|
||||||
|
}
|
||||||
private suspend fun getDisplayPreferences(
|
|
||||||
userId: UUID,
|
|
||||||
displayPreferencesId: String,
|
|
||||||
) = api.displayPreferencesApi
|
|
||||||
.getDisplayPreferences(
|
|
||||||
userId = userId,
|
|
||||||
displayPreferencesId = displayPreferencesId,
|
|
||||||
client = context.getString(R.string.app_name),
|
|
||||||
).content
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computes the filename for locally saved [HomePageSettings]
|
* Computes the filename for locally saved [HomePageSettings]
|
||||||
|
|
@ -245,6 +237,9 @@ class HomeSettingsService
|
||||||
currentSettings.update { resolvedSettings }
|
currentSettings.update { resolvedSettings }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the settings and set them to be the current settings
|
||||||
|
*/
|
||||||
suspend fun updateCurrent(settings: HomePageSettings) {
|
suspend fun updateCurrent(settings: HomePageSettings) {
|
||||||
val resolvedRows =
|
val resolvedRows =
|
||||||
settings.rows.mapIndexed { index, config ->
|
settings.rows.mapIndexed { index, config ->
|
||||||
|
|
@ -317,14 +312,17 @@ class HomeSettingsService
|
||||||
return HomePageResolvedSettings(rowConfig)
|
return HomePageResolvedSettings(rowConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create home page settings from the user's web UI home page settings
|
||||||
|
*/
|
||||||
suspend fun parseFromWebConfig(userId: UUID): HomePageResolvedSettings? {
|
suspend fun parseFromWebConfig(userId: UUID): HomePageResolvedSettings? {
|
||||||
val customPrefs =
|
val customPrefs =
|
||||||
api.displayPreferencesApi
|
displayPreferencesService
|
||||||
.getDisplayPreferences(
|
.getDisplayPreferences(
|
||||||
displayPreferencesId = "usersettings",
|
displayPreferencesId = "usersettings",
|
||||||
userId = userId,
|
userId = userId,
|
||||||
client = "emby",
|
client = "emby",
|
||||||
).content.customPrefs
|
).customPrefs
|
||||||
val userDto by api.userApi.getUserById(userId)
|
val userDto by api.userApi.getUserById(userId)
|
||||||
val config = userDto.configuration ?: DefaultUserConfiguration
|
val config = userDto.configuration ?: DefaultUserConfiguration
|
||||||
val libraries =
|
val libraries =
|
||||||
|
|
@ -438,10 +436,7 @@ class HomeSettingsService
|
||||||
): HomeRowConfigDisplay =
|
): HomeRowConfigDisplay =
|
||||||
when (config) {
|
when (config) {
|
||||||
is HomeRowConfig.ByParent -> {
|
is HomeRowConfig.ByParent -> {
|
||||||
val name =
|
val name = getItemName(config.parentId) ?: ""
|
||||||
api.userLibraryApi
|
|
||||||
.getItem(itemId = config.parentId)
|
|
||||||
.content.name ?: ""
|
|
||||||
HomeRowConfigDisplay(
|
HomeRowConfigDisplay(
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
|
|
@ -466,10 +461,7 @@ class HomeSettingsService
|
||||||
}
|
}
|
||||||
|
|
||||||
is HomeRowConfig.Genres -> {
|
is HomeRowConfig.Genres -> {
|
||||||
val name =
|
val name = getItemName(config.parentId) ?: ""
|
||||||
api.userLibraryApi
|
|
||||||
.getItem(itemId = config.parentId)
|
|
||||||
.content.name ?: ""
|
|
||||||
HomeRowConfigDisplay(
|
HomeRowConfigDisplay(
|
||||||
id,
|
id,
|
||||||
context.getString(R.string.genres_in, name),
|
context.getString(R.string.genres_in, name),
|
||||||
|
|
@ -477,6 +469,15 @@ class HomeSettingsService
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is HomeRowConfig.Studios -> {
|
||||||
|
val name = getItemName(config.parentId) ?: ""
|
||||||
|
HomeRowConfigDisplay(
|
||||||
|
id,
|
||||||
|
context.getString(R.string.studios_in, name),
|
||||||
|
config,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
is HomeRowConfig.GetItems -> {
|
is HomeRowConfig.GetItems -> {
|
||||||
HomeRowConfigDisplay(id, config.name, config)
|
HomeRowConfigDisplay(id, config.name, config)
|
||||||
}
|
}
|
||||||
|
|
@ -490,10 +491,7 @@ class HomeSettingsService
|
||||||
}
|
}
|
||||||
|
|
||||||
is HomeRowConfig.RecentlyAdded -> {
|
is HomeRowConfig.RecentlyAdded -> {
|
||||||
val name =
|
val name = getItemName(config.parentId) ?: ""
|
||||||
api.userLibraryApi
|
|
||||||
.getItem(itemId = config.parentId)
|
|
||||||
.content.name ?: ""
|
|
||||||
HomeRowConfigDisplay(
|
HomeRowConfigDisplay(
|
||||||
id,
|
id,
|
||||||
context.getString(R.string.recently_added_in, name),
|
context.getString(R.string.recently_added_in, name),
|
||||||
|
|
@ -502,10 +500,7 @@ class HomeSettingsService
|
||||||
}
|
}
|
||||||
|
|
||||||
is HomeRowConfig.RecentlyReleased -> {
|
is HomeRowConfig.RecentlyReleased -> {
|
||||||
val name =
|
val name = getItemName(config.parentId) ?: ""
|
||||||
api.userLibraryApi
|
|
||||||
.getItem(itemId = config.parentId)
|
|
||||||
.content.name ?: ""
|
|
||||||
HomeRowConfigDisplay(
|
HomeRowConfigDisplay(
|
||||||
id,
|
id,
|
||||||
context.getString(R.string.recently_released_in, name),
|
context.getString(R.string.recently_released_in, name),
|
||||||
|
|
@ -547,10 +542,7 @@ class HomeSettingsService
|
||||||
}
|
}
|
||||||
|
|
||||||
is HomeRowConfig.Suggestions -> {
|
is HomeRowConfig.Suggestions -> {
|
||||||
val name =
|
val name = getItemName(config.parentId) ?: ""
|
||||||
api.userLibraryApi
|
|
||||||
.getItem(itemId = config.parentId)
|
|
||||||
.content.name ?: ""
|
|
||||||
HomeRowConfigDisplay(
|
HomeRowConfigDisplay(
|
||||||
id = id,
|
id = id,
|
||||||
title = context.getString(R.string.suggestions_for, name),
|
title = context.getString(R.string.suggestions_for, name),
|
||||||
|
|
@ -559,6 +551,16 @@ class HomeSettingsService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun getItemName(itemId: UUID): String? =
|
||||||
|
try {
|
||||||
|
api.userLibraryApi
|
||||||
|
.getItem(itemId = itemId)
|
||||||
|
.content.name
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Could not get name for %s", itemId)
|
||||||
|
context.getString(R.string.unknown)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch the data from the server for a given [HomeRowConfig]
|
* Fetch the data from the server for a given [HomeRowConfig]
|
||||||
*/
|
*/
|
||||||
|
|
@ -585,6 +587,7 @@ class HomeSettingsService
|
||||||
title = context.getString(R.string.continue_watching),
|
title = context.getString(R.string.continue_watching),
|
||||||
items = resume,
|
items = resume,
|
||||||
viewOptions = row.viewOptions,
|
viewOptions = row.viewOptions,
|
||||||
|
rowType = row,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -603,6 +606,7 @@ class HomeSettingsService
|
||||||
title = context.getString(R.string.next_up),
|
title = context.getString(R.string.next_up),
|
||||||
items = nextUp,
|
items = nextUp,
|
||||||
viewOptions = row.viewOptions,
|
viewOptions = row.viewOptions,
|
||||||
|
rowType = row,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -632,6 +636,7 @@ class HomeSettingsService
|
||||||
nextUp,
|
nextUp,
|
||||||
),
|
),
|
||||||
viewOptions = row.viewOptions,
|
viewOptions = row.viewOptions,
|
||||||
|
rowType = row,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -691,6 +696,58 @@ class HomeSettingsService
|
||||||
title,
|
title,
|
||||||
genres,
|
genres,
|
||||||
viewOptions = row.viewOptions,
|
viewOptions = row.viewOptions,
|
||||||
|
rowType = row,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is HomeRowConfig.Studios -> {
|
||||||
|
val request =
|
||||||
|
GetStudiosRequest(
|
||||||
|
parentId = row.parentId,
|
||||||
|
userId = userDto.id,
|
||||||
|
limit = limit,
|
||||||
|
includeItemTypes = listOf(BaseItemKind.SERIES),
|
||||||
|
)
|
||||||
|
val items =
|
||||||
|
GetStudiosRequestHandler
|
||||||
|
.execute(api, request)
|
||||||
|
.content.items
|
||||||
|
val library =
|
||||||
|
libraries
|
||||||
|
.firstOrNull { it.itemId == row.parentId }
|
||||||
|
val title =
|
||||||
|
library?.name?.let { context.getString(R.string.studios_in, it) }
|
||||||
|
?: context.getString(R.string.studios)
|
||||||
|
val studios =
|
||||||
|
items.map {
|
||||||
|
val imageUrl =
|
||||||
|
imageUrlService.getItemImageUrl(
|
||||||
|
itemId = it.id,
|
||||||
|
imageType = ImageType.THUMB,
|
||||||
|
)
|
||||||
|
BaseItem(
|
||||||
|
it,
|
||||||
|
false,
|
||||||
|
imageUrl,
|
||||||
|
createStudioDestination(
|
||||||
|
studioId = it.id,
|
||||||
|
name = it.name ?: "",
|
||||||
|
parentId = row.parentId,
|
||||||
|
parentName = library?.name,
|
||||||
|
includeItemTypes =
|
||||||
|
library?.collectionType?.let {
|
||||||
|
getTypeFor(it)?.let {
|
||||||
|
listOf(it)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Success(
|
||||||
|
title,
|
||||||
|
studios,
|
||||||
|
viewOptions = row.viewOptions,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -718,6 +775,7 @@ class HomeSettingsService
|
||||||
title,
|
title,
|
||||||
it,
|
it,
|
||||||
row.viewOptions,
|
row.viewOptions,
|
||||||
|
rowType = row,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
latest
|
latest
|
||||||
|
|
@ -750,6 +808,7 @@ class HomeSettingsService
|
||||||
title,
|
title,
|
||||||
it,
|
it,
|
||||||
row.viewOptions,
|
row.viewOptions,
|
||||||
|
rowType = row,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -778,6 +837,7 @@ class HomeSettingsService
|
||||||
name ?: context.getString(R.string.collection),
|
name ?: context.getString(R.string.collection),
|
||||||
it,
|
it,
|
||||||
row.viewOptions,
|
row.viewOptions,
|
||||||
|
rowType = row,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -805,6 +865,7 @@ class HomeSettingsService
|
||||||
row.name,
|
row.name,
|
||||||
it,
|
it,
|
||||||
row.viewOptions,
|
row.viewOptions,
|
||||||
|
rowType = row,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -855,6 +916,7 @@ class HomeSettingsService
|
||||||
title,
|
title,
|
||||||
it,
|
it,
|
||||||
row.viewOptions,
|
row.viewOptions,
|
||||||
|
rowType = row,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -879,6 +941,7 @@ class HomeSettingsService
|
||||||
context.getString(R.string.active_recordings),
|
context.getString(R.string.active_recordings),
|
||||||
it,
|
it,
|
||||||
row.viewOptions,
|
row.viewOptions,
|
||||||
|
rowType = row,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -891,7 +954,7 @@ class HomeSettingsService
|
||||||
limit = limit,
|
limit = limit,
|
||||||
enableUserData = true,
|
enableUserData = true,
|
||||||
enableImages = true,
|
enableImages = true,
|
||||||
enableImageTypes = listOf(ImageType.PRIMARY),
|
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.LOGO),
|
||||||
imageTypeLimit = 1,
|
imageTypeLimit = 1,
|
||||||
)
|
)
|
||||||
api.liveTvApi
|
api.liveTvApi
|
||||||
|
|
@ -903,6 +966,7 @@ class HomeSettingsService
|
||||||
context.getString(R.string.live_tv),
|
context.getString(R.string.live_tv),
|
||||||
it,
|
it,
|
||||||
row.viewOptions,
|
row.viewOptions,
|
||||||
|
rowType = row,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -920,6 +984,7 @@ class HomeSettingsService
|
||||||
context.getString(R.string.channels),
|
context.getString(R.string.channels),
|
||||||
it,
|
it,
|
||||||
row.viewOptions,
|
row.viewOptions,
|
||||||
|
rowType = row,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -942,12 +1007,14 @@ class HomeSettingsService
|
||||||
title,
|
title,
|
||||||
suggestions.items,
|
suggestions.items,
|
||||||
row.viewOptions,
|
row.viewOptions,
|
||||||
|
rowType = row,
|
||||||
)
|
)
|
||||||
} else if (suggestions is SuggestionsResource.Empty) {
|
} else if (suggestions is SuggestionsResource.Empty) {
|
||||||
Success(
|
Success(
|
||||||
title,
|
title,
|
||||||
listOf(),
|
listOf(),
|
||||||
row.viewOptions,
|
row.viewOptions,
|
||||||
|
rowType = row,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Error(
|
Error(
|
||||||
|
|
@ -959,7 +1026,6 @@ class HomeSettingsService
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val DISPLAY_PREF_ID = "default"
|
|
||||||
const val CUSTOM_PREF_ID = "home_settings"
|
const val CUSTOM_PREF_ID = "home_settings"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import com.github.damontecres.wholphin.R
|
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
import com.github.damontecres.wholphin.util.LocalDateTimeSerializer
|
||||||
import com.github.damontecres.wholphin.util.supportItemKinds
|
import com.github.damontecres.wholphin.util.supportItemKinds
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
import kotlinx.coroutines.sync.withPermit
|
import kotlinx.coroutines.sync.withPermit
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.UseSerializers
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
|
||||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.CollectionType
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
import org.jellyfin.sdk.model.api.UserDto
|
|
||||||
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
|
||||||
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
|
||||||
|
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
@ -32,14 +34,21 @@ import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get continue watching and next up items for users
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class LatestNextUpService
|
class LatestNextUpService
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
@param:ApplicationContext private val context: Context,
|
|
||||||
private val api: ApiClient,
|
private val api: ApiClient,
|
||||||
private val datePlayedService: DatePlayedService,
|
private val datePlayedService: DatePlayedService,
|
||||||
|
private val displayPreferencesService: DisplayPreferencesService,
|
||||||
|
private val favoriteWatchManager: FavoriteWatchManager,
|
||||||
) {
|
) {
|
||||||
|
/**
|
||||||
|
* Get resume (continue watching) items for a user
|
||||||
|
*/
|
||||||
suspend fun getResume(
|
suspend fun getResume(
|
||||||
userId: UUID,
|
userId: UUID,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
|
|
@ -61,12 +70,6 @@ class LatestNextUpService
|
||||||
remove(BaseItemKind.EPISODE)
|
remove(BaseItemKind.EPISODE)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enableImageTypes =
|
|
||||||
listOf(
|
|
||||||
ImageType.PRIMARY,
|
|
||||||
ImageType.THUMB,
|
|
||||||
ImageType.BACKDROP,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
val items =
|
val items =
|
||||||
api.itemsApi
|
api.itemsApi
|
||||||
|
|
@ -77,6 +80,9 @@ class LatestNextUpService
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get next up items for a user
|
||||||
|
*/
|
||||||
suspend fun getNextUp(
|
suspend fun getNextUp(
|
||||||
userId: UUID,
|
userId: UUID,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
|
|
@ -85,6 +91,7 @@ class LatestNextUpService
|
||||||
maxDays: Int,
|
maxDays: Int,
|
||||||
useSeriesForPrimary: Boolean = true,
|
useSeriesForPrimary: Boolean = true,
|
||||||
): List<BaseItem> {
|
): List<BaseItem> {
|
||||||
|
val removedSeries = getRemovedFromNextUp(userId)
|
||||||
val nextUpDateCutoff =
|
val nextUpDateCutoff =
|
||||||
maxDays.takeIf { it > 0 }?.let { LocalDateTime.now().minusDays(it.toLong()) }
|
maxDays.takeIf { it > 0 }?.let { LocalDateTime.now().minusDays(it.toLong()) }
|
||||||
val request =
|
val request =
|
||||||
|
|
@ -105,68 +112,31 @@ class LatestNextUpService
|
||||||
.content
|
.content
|
||||||
.items
|
.items
|
||||||
.map { BaseItem.from(it, api, useSeriesForPrimary) }
|
.map { BaseItem.from(it, api, useSeriesForPrimary) }
|
||||||
|
.filter {
|
||||||
|
val seriesId = it.data.seriesId
|
||||||
|
if (seriesId != null && seriesId in removedSeries) {
|
||||||
|
// User has previously removed the series
|
||||||
|
val lastPlayedDate = it.data.userData?.lastPlayedDate
|
||||||
|
if (lastPlayedDate != null) {
|
||||||
|
// If item played it after it was removed, should include it
|
||||||
|
lastPlayedDate > removedSeries[seriesId]
|
||||||
|
} else {
|
||||||
|
// If unknown last played, filter out
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nextUp
|
return nextUp
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getLatest(
|
/**
|
||||||
user: UserDto,
|
* Create the combined Continue Watching & Next Up items
|
||||||
limit: Int,
|
*
|
||||||
includedIds: List<UUID>,
|
* @see [DatePlayedService]
|
||||||
): List<LatestData> {
|
*/
|
||||||
val excluded = user.configuration?.latestItemsExcludes.orEmpty()
|
|
||||||
val views by api.userViewsApi.getUserViews()
|
|
||||||
val latestData =
|
|
||||||
views.items
|
|
||||||
.filter {
|
|
||||||
it.id in includedIds && it.id !in excluded &&
|
|
||||||
it.collectionType in supportedLatestCollectionTypes
|
|
||||||
}.map { view ->
|
|
||||||
val title =
|
|
||||||
view.name?.let { context.getString(R.string.recently_added_in, it) }
|
|
||||||
?: context.getString(R.string.recently_added)
|
|
||||||
val request =
|
|
||||||
GetLatestMediaRequest(
|
|
||||||
fields = SlimItemFields,
|
|
||||||
imageTypeLimit = 1,
|
|
||||||
parentId = view.id,
|
|
||||||
groupItems = true,
|
|
||||||
limit = limit,
|
|
||||||
isPlayed = null, // Server will handle user's preference
|
|
||||||
)
|
|
||||||
LatestData(title, request)
|
|
||||||
}
|
|
||||||
|
|
||||||
return latestData
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun loadLatest(latestData: List<LatestData>): List<HomeRowLoadingState> {
|
|
||||||
val rows =
|
|
||||||
latestData.mapNotNull { (title, request) ->
|
|
||||||
try {
|
|
||||||
val latest =
|
|
||||||
api.userLibraryApi
|
|
||||||
.getLatestMedia(request)
|
|
||||||
.content
|
|
||||||
.map { BaseItem.from(it, api, true) }
|
|
||||||
if (latest.isNotEmpty()) {
|
|
||||||
HomeRowLoadingState.Success(
|
|
||||||
title = title,
|
|
||||||
items = latest,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
} catch (ex: Exception) {
|
|
||||||
Timber.e(ex, "Exception fetching %s", title)
|
|
||||||
HomeRowLoadingState.Error(
|
|
||||||
title = title,
|
|
||||||
exception = ex,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun buildCombined(
|
suspend fun buildCombined(
|
||||||
resume: List<BaseItem>,
|
resume: List<BaseItem>,
|
||||||
nextUp: List<BaseItem>,
|
nextUp: List<BaseItem>,
|
||||||
|
|
@ -199,18 +169,112 @@ class LatestNextUpService
|
||||||
Timber.v("buildCombined took %s", duration)
|
Timber.v("buildCombined took %s", duration)
|
||||||
return@withContext result
|
return@withContext result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a series from next up
|
||||||
|
*/
|
||||||
|
suspend fun removeFromNextUp(
|
||||||
|
userId: UUID,
|
||||||
|
episode: BaseItem,
|
||||||
|
) {
|
||||||
|
favoriteWatchManager.setWatched(episode.id, false)
|
||||||
|
episode.data.seriesId?.let { seriesId ->
|
||||||
|
displayPreferencesService.updateDisplayPreferences(userId) {
|
||||||
|
val removedIds =
|
||||||
|
get(REMOVED_KEY)
|
||||||
|
?.let {
|
||||||
|
Json.decodeFromString<RemovedSeriesIds>(it).value
|
||||||
|
}.orEmpty()
|
||||||
|
.toMutableMap()
|
||||||
|
removedIds[seriesId] = LocalDateTime.now()
|
||||||
|
put(
|
||||||
|
REMOVED_KEY,
|
||||||
|
Json.encodeToString(RemovedSeriesIds(removedIds)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get when series were removed from next up
|
||||||
|
*/
|
||||||
|
suspend fun getRemovedFromNextUp(userId: UUID): Map<UUID, LocalDateTime> =
|
||||||
|
displayPreferencesService
|
||||||
|
.getDisplayPreferences(userId)
|
||||||
|
.customPrefs[REMOVED_KEY]
|
||||||
|
?.let {
|
||||||
|
Json.decodeFromString<RemovedSeriesIds>(it).value
|
||||||
|
}.orEmpty()
|
||||||
|
|
||||||
|
suspend fun allowSeriesRemovedFromNextUp(
|
||||||
|
userId: UUID,
|
||||||
|
seriesId: UUID,
|
||||||
|
) {
|
||||||
|
displayPreferencesService.updateDisplayPreferences(userId) {
|
||||||
|
val ids =
|
||||||
|
get(REMOVED_KEY)
|
||||||
|
?.let {
|
||||||
|
Json.decodeFromString<RemovedSeriesIds>(it).value
|
||||||
|
}.orEmpty()
|
||||||
|
.toMutableMap()
|
||||||
|
ids.remove(seriesId)
|
||||||
|
put(
|
||||||
|
REMOVED_KEY,
|
||||||
|
Json.encodeToString(RemovedSeriesIds(ids)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has watched a series since removing it
|
||||||
|
*/
|
||||||
|
suspend fun updateRemovedFromNextUp(userId: UUID) {
|
||||||
|
val removed = getRemovedFromNextUp(userId)
|
||||||
|
val newRemoved = removed.toMutableMap()
|
||||||
|
var changed = false
|
||||||
|
removed.forEach { (seriesId, timestamp) ->
|
||||||
|
val item =
|
||||||
|
api.itemsApi
|
||||||
|
.getItems(
|
||||||
|
userId = userId,
|
||||||
|
parentId = seriesId,
|
||||||
|
recursive = true,
|
||||||
|
includeItemTypes = listOf(BaseItemKind.EPISODE),
|
||||||
|
sortBy = listOf(ItemSortBy.DATE_PLAYED),
|
||||||
|
sortOrder = listOf(SortOrder.DESCENDING),
|
||||||
|
limit = 1,
|
||||||
|
).content.items
|
||||||
|
.firstOrNull()
|
||||||
|
if (item != null) {
|
||||||
|
val lastPlayed = item.userData?.lastPlayedDate
|
||||||
|
if (lastPlayed != null && lastPlayed > timestamp) {
|
||||||
|
Timber.v("Updating removed next up for series %s", seriesId)
|
||||||
|
newRemoved.remove(seriesId)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Series doesn't exist anymore
|
||||||
|
Timber.v("Updating removed next up for missing series %s", seriesId)
|
||||||
|
newRemoved.remove(seriesId)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
displayPreferencesService.updateDisplayPreferences(userId) {
|
||||||
|
put(
|
||||||
|
REMOVED_KEY,
|
||||||
|
Json.encodeToString(RemovedSeriesIds(newRemoved)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val REMOVED_KEY = "removeNextUp"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val supportedLatestCollectionTypes =
|
@Serializable
|
||||||
setOf(
|
data class RemovedSeriesIds(
|
||||||
CollectionType.MOVIES,
|
val value: Map<UUID, LocalDateTime>,
|
||||||
CollectionType.TVSHOWS,
|
|
||||||
CollectionType.HOMEVIDEOS,
|
|
||||||
// Exclude Live TV because a recording folder view will be used instead
|
|
||||||
null, // Recordings & mixed collection types
|
|
||||||
)
|
|
||||||
|
|
||||||
data class LatestData(
|
|
||||||
val title: String,
|
|
||||||
val request: GetLatestMediaRequest,
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service to manage media such as deletions
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class MediaManagementService
|
class MediaManagementService
|
||||||
@Inject
|
@Inject
|
||||||
|
|
@ -45,6 +48,9 @@ class MediaManagementService
|
||||||
return canDelete(item, appPreferences)
|
return canDelete(item, appPreferences)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the item can be deleted. This means the app setting is enabled and the user has permission.
|
||||||
|
*/
|
||||||
fun canDelete(
|
fun canDelete(
|
||||||
item: BaseItem,
|
item: BaseItem,
|
||||||
appPreferences: AppPreferences,
|
appPreferences: AppPreferences,
|
||||||
|
|
@ -62,10 +68,14 @@ class MediaManagementService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the item.
|
||||||
|
*
|
||||||
|
* This item will be sent through [deletedItemFlow] for other services or view models to react.
|
||||||
|
*/
|
||||||
suspend fun deleteItem(item: BaseItem): DeleteResult {
|
suspend fun deleteItem(item: BaseItem): DeleteResult {
|
||||||
try {
|
try {
|
||||||
Timber.i("Deleting %s", item.id)
|
Timber.i("Deleting %s", item.id)
|
||||||
// TODO enable
|
|
||||||
api.libraryApi.deleteItem(item.id)
|
api.libraryApi.deleteItem(item.id)
|
||||||
_deletedItemFlow.emit(DeletedItem(item))
|
_deletedItemFlow.emit(DeletedItem(item))
|
||||||
return DeleteResult.Success
|
return DeleteResult.Success
|
||||||
|
|
@ -88,6 +98,9 @@ sealed interface DeleteResult {
|
||||||
) : DeleteResult
|
) : DeleteResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience function to delete an item and show a Toast based on success or error
|
||||||
|
*/
|
||||||
fun ViewModel.deleteItem(
|
fun ViewModel.deleteItem(
|
||||||
context: Context,
|
context: Context,
|
||||||
mediaManagementService: MediaManagementService,
|
mediaManagementService: MediaManagementService,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,9 @@ import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send media info to the server
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class MediaReportService
|
class MediaReportService
|
||||||
@Inject
|
@Inject
|
||||||
|
|
@ -39,6 +42,9 @@ class MediaReportService
|
||||||
encodeDefaults = false
|
encodeDefaults = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the media info and send it to the server
|
||||||
|
*/
|
||||||
fun sendReportFor(itemId: UUID) {
|
fun sendReportFor(itemId: UUID) {
|
||||||
ioScope.launchIO(ExceptionHandler(autoToast = true)) {
|
ioScope.launchIO(ExceptionHandler(autoToast = true)) {
|
||||||
val item = api.userLibraryApi.getItem(itemId = itemId).content
|
val item = api.userLibraryApi.getItem(itemId = itemId).content
|
||||||
|
|
@ -46,6 +52,9 @@ class MediaReportService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send the media report for the given item
|
||||||
|
*/
|
||||||
suspend fun sendReportFor(item: BaseItemDto) {
|
suspend fun sendReportFor(item: BaseItemDto) {
|
||||||
val sources =
|
val sources =
|
||||||
item.mediaSources ?: api.userLibraryApi
|
item.mediaSources ?: api.userLibraryApi
|
||||||
|
|
|
||||||
|
|
@ -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.JellyfinUser
|
||||||
import com.github.damontecres.wholphin.data.model.NavPinType
|
import com.github.damontecres.wholphin.data.model.NavPinType
|
||||||
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
|
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
|
||||||
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
import com.github.damontecres.wholphin.ui.main.settings.Library
|
import com.github.damontecres.wholphin.ui.main.settings.Library
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
|
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
|
||||||
|
|
@ -16,9 +17,11 @@ import com.github.damontecres.wholphin.util.supportedCollectionTypes
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
|
@ -33,7 +36,11 @@ import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the items to show in the nav drawer
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class NavDrawerService
|
class NavDrawerService
|
||||||
@Inject
|
@Inject
|
||||||
|
|
@ -44,11 +51,13 @@ class NavDrawerService
|
||||||
private val serverRepository: ServerRepository,
|
private val serverRepository: ServerRepository,
|
||||||
private val serverPreferencesDao: ServerPreferencesDao,
|
private val serverPreferencesDao: ServerPreferencesDao,
|
||||||
private val seerrServerRepository: SeerrServerRepository,
|
private val seerrServerRepository: SeerrServerRepository,
|
||||||
|
private val musicService: MusicService,
|
||||||
) {
|
) {
|
||||||
private val _state = MutableStateFlow(NavDrawerItemState.EMPTY)
|
private val _state = MutableStateFlow(NavDrawerItemState.EMPTY)
|
||||||
val state: StateFlow<NavDrawerItemState> = _state
|
val state: StateFlow<NavDrawerItemState> = _state
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
// Handle updating the nav drawer when the user changes
|
||||||
serverRepository.currentUser
|
serverRepository.currentUser
|
||||||
.asFlow()
|
.asFlow()
|
||||||
.combine(serverRepository.currentUserDto.asFlow()) { user, userDto ->
|
.combine(serverRepository.currentUserDto.asFlow()) { user, userDto ->
|
||||||
|
|
@ -69,12 +78,52 @@ class NavDrawerService
|
||||||
showToast(context, "Error fetching user's views")
|
showToast(context, "Error fetching user's views")
|
||||||
}.launchIn(coroutineScope)
|
}.launchIn(coroutineScope)
|
||||||
|
|
||||||
|
// Handle when the user has logged into a Seerr server
|
||||||
seerrServerRepository.active
|
seerrServerRepository.active
|
||||||
.onEach { discoverActive ->
|
.onEach { discoverActive ->
|
||||||
_state.update { it.copy(discoverEnabled = discoverActive) }
|
_state.update { it.copy(discoverEnabled = discoverActive) }
|
||||||
}.launchIn(coroutineScope)
|
}.launchIn(coroutineScope)
|
||||||
|
|
||||||
|
// Handle when music is actively playing or not
|
||||||
|
coroutineScope.launchDefault {
|
||||||
|
musicService.state.collectLatest { music ->
|
||||||
|
Timber.v("MusicService updated")
|
||||||
|
when (music.status) {
|
||||||
|
NowPlayingStatus.PLAYING -> {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
nowPlayingEnabled = true,
|
||||||
|
nowPlayingTitle = music.currentItemTitle,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NowPlayingStatus.IDLE -> {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
nowPlayingEnabled = false,
|
||||||
|
nowPlayingTitle = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NowPlayingStatus.PAUSED -> {
|
||||||
|
delay(2.hours)
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
nowPlayingEnabled = false,
|
||||||
|
nowPlayingTitle = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all the libraries the user has access to
|
||||||
|
*/
|
||||||
suspend fun getAllUserLibraries(
|
suspend fun getAllUserLibraries(
|
||||||
userId: UUID,
|
userId: UUID,
|
||||||
tvAccess: Boolean,
|
tvAccess: Boolean,
|
||||||
|
|
@ -108,6 +157,9 @@ class NavDrawerService
|
||||||
return libraries
|
return libraries
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the libraries that the user has not "pinned". These will show in the More section.
|
||||||
|
*/
|
||||||
suspend fun getFilteredUserLibraries(
|
suspend fun getFilteredUserLibraries(
|
||||||
user: JellyfinUser,
|
user: JellyfinUser,
|
||||||
tvAccess: Boolean,
|
tvAccess: Boolean,
|
||||||
|
|
@ -122,6 +174,9 @@ class NavDrawerService
|
||||||
return libraries
|
return libraries
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the current state of the nav drawer items
|
||||||
|
*/
|
||||||
suspend fun updateNavDrawer(
|
suspend fun updateNavDrawer(
|
||||||
user: JellyfinUser,
|
user: JellyfinUser,
|
||||||
userDto: UserDto,
|
userDto: UserDto,
|
||||||
|
|
@ -185,9 +240,11 @@ data class NavDrawerItemState(
|
||||||
val items: List<NavDrawerItem>,
|
val items: List<NavDrawerItem>,
|
||||||
val moreItems: List<NavDrawerItem>,
|
val moreItems: List<NavDrawerItem>,
|
||||||
val discoverEnabled: Boolean,
|
val discoverEnabled: Boolean,
|
||||||
|
val nowPlayingEnabled: Boolean,
|
||||||
|
val nowPlayingTitle: String?,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false)
|
val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false, false, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
package com.github.damontecres.wholphin.services
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
import androidx.navigation3.runtime.NavKey
|
import androidx.navigation3.runtime.NavBackStack
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import org.acra.ACRA
|
import org.acra.ACRA
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -14,7 +14,7 @@ import javax.inject.Singleton
|
||||||
class NavigationManager
|
class NavigationManager
|
||||||
@Inject
|
@Inject
|
||||||
constructor() {
|
constructor() {
|
||||||
var backStack: MutableList<NavKey> = mutableListOf()
|
var backStack: MutableList<Destination> = NavBackStack(Destination.Home())
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Go to the specified [com.github.damontecres.wholphin.ui.nav.Destination]
|
* Go to the specified [com.github.damontecres.wholphin.ui.nav.Destination]
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@ import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets people in media, specifically to check if they are favorited or not
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class PeopleFavorites
|
class PeopleFavorites
|
||||||
@Inject
|
@Inject
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
import androidx.lifecycle.DefaultLifecycleObserver
|
import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
|
||||||
import dagger.hilt.android.scopes.ActivityRetainedScoped
|
import dagger.hilt.android.scopes.ActivityRetainedScoped
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
@ -20,13 +19,14 @@ class PlaybackLifecycleObserver
|
||||||
private var wasPlaying: Boolean? = null
|
private var wasPlaying: Boolean? = null
|
||||||
|
|
||||||
override fun onStart(owner: LifecycleOwner) {
|
override fun onStart(owner: LifecycleOwner) {
|
||||||
val lastDest = navigationManager.backStack.lastOrNull()
|
// TODO
|
||||||
if (lastDest is Destination.Playback ||
|
// val lastDest = navigationManager.backStack.lastOrNull()
|
||||||
lastDest is Destination.PlaybackList ||
|
// if (lastDest is Destination.Playback ||
|
||||||
lastDest is Destination.Slideshow
|
// lastDest is Destination.PlaybackList ||
|
||||||
) {
|
// lastDest is Destination.Slideshow
|
||||||
navigationManager.goBack()
|
// ) {
|
||||||
}
|
// navigationManager.goBack()
|
||||||
|
// }
|
||||||
wasPlaying = null
|
wasPlaying = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,27 +6,39 @@ import android.content.Context
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.media3.common.AudioAttributes
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.TrackSelectionParameters.AudioOffloadPreferences
|
||||||
|
import androidx.media3.common.util.ExperimentalApi
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.datasource.DefaultDataSource
|
||||||
|
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.exoplayer.Renderer
|
import androidx.media3.exoplayer.Renderer
|
||||||
|
import androidx.media3.exoplayer.RenderersFactory
|
||||||
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
|
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
|
||||||
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
|
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||||
import androidx.media3.exoplayer.video.MediaCodecVideoRenderer
|
import androidx.media3.exoplayer.video.MediaCodecVideoRenderer
|
||||||
import androidx.media3.exoplayer.video.VideoRendererEventListener
|
import androidx.media3.exoplayer.video.VideoRendererEventListener
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
import androidx.media3.extractor.DefaultExtractorsFactory
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AssPlaybackMode
|
||||||
import com.github.damontecres.wholphin.preferences.MediaExtensionStatus
|
import com.github.damontecres.wholphin.preferences.MediaExtensionStatus
|
||||||
import com.github.damontecres.wholphin.preferences.PlaybackPreferences
|
import com.github.damontecres.wholphin.preferences.PlaybackPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||||
|
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
||||||
import com.github.damontecres.wholphin.util.mpv.MpvPlayer
|
import com.github.damontecres.wholphin.util.mpv.MpvPlayer
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import io.github.peerless2012.ass.media.AssHandler
|
||||||
|
import io.github.peerless2012.ass.media.factory.AssRenderersFactory
|
||||||
|
import io.github.peerless2012.ass.media.kt.withAssMkvSupport
|
||||||
|
import io.github.peerless2012.ass.media.parser.AssSubtitleParserFactory
|
||||||
|
import io.github.peerless2012.ass.media.type.AssRenderType
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.lang.reflect.Constructor
|
import java.lang.reflect.Constructor
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -40,77 +52,23 @@ class PlayerFactory
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
private val appPreferences: DataStore<AppPreferences>,
|
@param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient,
|
||||||
) {
|
) {
|
||||||
@Volatile
|
@Volatile
|
||||||
var currentPlayer: Player? = null
|
var currentPlayer: Player? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
fun createVideoPlayer(): Player {
|
|
||||||
if (currentPlayer?.isReleased == false) {
|
|
||||||
Timber.w("Player was not released before trying to create a new one!")
|
|
||||||
currentPlayer?.release()
|
|
||||||
}
|
|
||||||
|
|
||||||
val prefs = runBlocking { appPreferences.data.firstOrNull()?.playbackPreferences }
|
|
||||||
val backend = prefs?.playerBackend ?: AppPreference.PlayerBackendPref.defaultValue
|
|
||||||
val newPlayer =
|
|
||||||
when (backend) {
|
|
||||||
PlayerBackend.PREFER_MPV,
|
|
||||||
PlayerBackend.MPV,
|
|
||||||
-> {
|
|
||||||
val enableHardwareDecoding =
|
|
||||||
prefs?.mpvOptions?.enableHardwareDecoding
|
|
||||||
?: AppPreference.MpvHardwareDecoding.defaultValue
|
|
||||||
val useGpuNext =
|
|
||||||
prefs?.mpvOptions?.useGpuNext
|
|
||||||
?: AppPreference.MpvGpuNext.defaultValue
|
|
||||||
MpvPlayer(context, enableHardwareDecoding, useGpuNext)
|
|
||||||
.apply {
|
|
||||||
playWhenReady = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
PlayerBackend.EXO_PLAYER,
|
|
||||||
PlayerBackend.UNRECOGNIZED,
|
|
||||||
-> {
|
|
||||||
val extensions = prefs?.overrides?.mediaExtensionsEnabled
|
|
||||||
val decodeAv1 = prefs?.overrides?.decodeAv1 == true
|
|
||||||
Timber.v("extensions=$extensions")
|
|
||||||
val rendererMode =
|
|
||||||
when (extensions) {
|
|
||||||
MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
|
|
||||||
MediaExtensionStatus.MES_PREFERRED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
|
|
||||||
MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF
|
|
||||||
else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
|
|
||||||
}
|
|
||||||
ExoPlayer
|
|
||||||
.Builder(context)
|
|
||||||
.setRenderersFactory(
|
|
||||||
WholphinRenderersFactory(context, decodeAv1)
|
|
||||||
.setEnableDecoderFallback(true)
|
|
||||||
.setExtensionRendererMode(rendererMode),
|
|
||||||
).build()
|
|
||||||
.apply {
|
|
||||||
playWhenReady = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
currentPlayer = newPlayer
|
|
||||||
return newPlayer
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun createVideoPlayer(
|
suspend fun createVideoPlayer(
|
||||||
backend: PlayerBackend,
|
backend: PlayerBackend,
|
||||||
prefs: PlaybackPreferences,
|
prefs: PlaybackPreferences,
|
||||||
): Player {
|
): PlayerCreation {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
if (currentPlayer?.isReleased == false) {
|
if (currentPlayer?.isReleased == false) {
|
||||||
Timber.w("Player was not released before trying to create a new one!")
|
Timber.w("Player was not released before trying to create a new one!")
|
||||||
currentPlayer?.release()
|
currentPlayer?.release()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var assHandler: AssHandler? = null
|
||||||
val newPlayer =
|
val newPlayer =
|
||||||
when (backend) {
|
when (backend) {
|
||||||
PlayerBackend.PREFER_MPV,
|
PlayerBackend.PREFER_MPV,
|
||||||
|
|
@ -125,8 +83,14 @@ class PlayerFactory
|
||||||
PlayerBackend.UNRECOGNIZED,
|
PlayerBackend.UNRECOGNIZED,
|
||||||
-> {
|
-> {
|
||||||
val extensions = prefs.overrides.mediaExtensionsEnabled
|
val extensions = prefs.overrides.mediaExtensionsEnabled
|
||||||
|
val useLibAss =
|
||||||
|
prefs.overrides.assPlaybackMode == AssPlaybackMode.ASS_LIBASS
|
||||||
val decodeAv1 = prefs.overrides.decodeAv1
|
val decodeAv1 = prefs.overrides.decodeAv1
|
||||||
Timber.v("extensions=$extensions")
|
Timber.v(
|
||||||
|
"extensions=%s, assPlaybackMode=%s",
|
||||||
|
extensions,
|
||||||
|
prefs.overrides.assPlaybackMode,
|
||||||
|
)
|
||||||
val rendererMode =
|
val rendererMode =
|
||||||
when (extensions) {
|
when (extensions) {
|
||||||
MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
|
MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
|
||||||
|
|
@ -134,18 +98,113 @@ class PlayerFactory
|
||||||
MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF
|
MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF
|
||||||
else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
|
else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
|
||||||
}
|
}
|
||||||
|
val dataSourceFactory = DefaultDataSource.Factory(context)
|
||||||
|
val extractorsFactory = createExtractorsFactory()
|
||||||
|
var renderersFactory: RenderersFactory =
|
||||||
|
WholphinRenderersFactory(context, decodeAv1)
|
||||||
|
.setEnableDecoderFallback(true)
|
||||||
|
.setExtensionRendererMode(rendererMode)
|
||||||
|
val mediaSourceFactory =
|
||||||
|
if (useLibAss) {
|
||||||
|
assHandler = AssHandler(AssRenderType.OVERLAY_OPEN_GL)
|
||||||
|
val assSubtitleParserFactory = AssSubtitleParserFactory(assHandler)
|
||||||
|
renderersFactory = AssRenderersFactory(assHandler, renderersFactory)
|
||||||
|
DefaultMediaSourceFactory(
|
||||||
|
dataSourceFactory,
|
||||||
|
extractorsFactory.withAssMkvSupport(
|
||||||
|
assSubtitleParserFactory,
|
||||||
|
assHandler,
|
||||||
|
),
|
||||||
|
).setSubtitleParserFactory(assSubtitleParserFactory)
|
||||||
|
} else {
|
||||||
|
DefaultMediaSourceFactory(
|
||||||
|
dataSourceFactory,
|
||||||
|
extractorsFactory,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val trackSelector = createTrackSelector()
|
||||||
|
|
||||||
ExoPlayer
|
ExoPlayer
|
||||||
.Builder(context)
|
.Builder(context)
|
||||||
.setRenderersFactory(
|
.setMediaSourceFactory(mediaSourceFactory)
|
||||||
WholphinRenderersFactory(context, decodeAv1)
|
.setRenderersFactory(renderersFactory)
|
||||||
.setEnableDecoderFallback(true)
|
.setTrackSelector(trackSelector)
|
||||||
.setExtensionRendererMode(rendererMode),
|
.build()
|
||||||
).build()
|
.apply {
|
||||||
|
assHandler?.init(this)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
setAudioAttributes(
|
||||||
|
AudioAttributes
|
||||||
|
.Builder()
|
||||||
|
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||||
|
.build(),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayerBackend.EXTERNAL_PLAYER -> {
|
||||||
|
throw IllegalArgumentException("Cannot create a player for external playback")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
currentPlayer = newPlayer
|
currentPlayer = newPlayer
|
||||||
return newPlayer
|
return PlayerCreation(newPlayer, assHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun createAudioPlayer(extensions: MediaExtensionStatus = MediaExtensionStatus.MES_FALLBACK): ExoPlayer {
|
||||||
|
val rendererMode =
|
||||||
|
when (extensions) {
|
||||||
|
MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
|
||||||
|
MediaExtensionStatus.MES_PREFERRED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
|
||||||
|
MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF
|
||||||
|
else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
|
||||||
|
}
|
||||||
|
val extractorsFactory = createExtractorsFactory()
|
||||||
|
val renderersFactory: RenderersFactory =
|
||||||
|
WholphinRenderersFactory(context, false)
|
||||||
|
.setEnableDecoderFallback(true)
|
||||||
|
.setExtensionRendererMode(rendererMode)
|
||||||
|
val mediaSourceFactory =
|
||||||
|
DefaultMediaSourceFactory(
|
||||||
|
OkHttpDataSource.Factory(authOkHttpClient),
|
||||||
|
extractorsFactory,
|
||||||
|
)
|
||||||
|
val trackSelector = createTrackSelector()
|
||||||
|
return ExoPlayer
|
||||||
|
.Builder(context)
|
||||||
|
.setMediaSourceFactory(mediaSourceFactory)
|
||||||
|
.setRenderersFactory(renderersFactory)
|
||||||
|
.setTrackSelector(trackSelector)
|
||||||
|
.build()
|
||||||
|
.also {
|
||||||
|
it.setAudioAttributes(
|
||||||
|
AudioAttributes
|
||||||
|
.Builder()
|
||||||
|
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||||
|
.build(),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createExtractorsFactory() =
|
||||||
|
DefaultExtractorsFactory()
|
||||||
|
.setConstantBitrateSeekingEnabled(true)
|
||||||
|
.setConstantBitrateSeekingAlwaysEnabled(true)
|
||||||
|
|
||||||
|
private fun createTrackSelector() =
|
||||||
|
DefaultTrackSelector(context).apply {
|
||||||
|
setParameters(
|
||||||
|
buildUponParameters()
|
||||||
|
.setAudioOffloadPreferences(
|
||||||
|
AudioOffloadPreferences
|
||||||
|
.Builder()
|
||||||
|
.setAudioOffloadMode(AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED)
|
||||||
|
.build(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val Player.isReleased: Boolean
|
val Player.isReleased: Boolean
|
||||||
|
|
@ -157,11 +216,17 @@ val Player.isReleased: Boolean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class PlayerCreation(
|
||||||
|
val player: Player,
|
||||||
|
val assHandler: AssHandler? = null,
|
||||||
|
)
|
||||||
|
|
||||||
// Code is adapted from https://github.com/androidx/media/blob/release/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/DefaultRenderersFactory.java#L436
|
// Code is adapted from https://github.com/androidx/media/blob/release/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/DefaultRenderersFactory.java#L436
|
||||||
class WholphinRenderersFactory(
|
class WholphinRenderersFactory(
|
||||||
context: Context,
|
context: Context,
|
||||||
private val av1Enabled: Boolean,
|
private val av1Enabled: Boolean,
|
||||||
) : DefaultRenderersFactory(context) {
|
) : DefaultRenderersFactory(context) {
|
||||||
|
@OptIn(ExperimentalApi::class)
|
||||||
override fun buildVideoRenderers(
|
override fun buildVideoRenderers(
|
||||||
context: Context,
|
context: Context,
|
||||||
extensionRendererMode: Int,
|
extensionRendererMode: Int,
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,11 @@ import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create [Playlist]s (not Jellyfin server playlist) for playback
|
||||||
|
*
|
||||||
|
* Used to create a queue of episodes or from Play All button
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class PlaylistCreator
|
class PlaylistCreator
|
||||||
@Inject
|
@Inject
|
||||||
|
|
@ -74,6 +79,9 @@ class PlaylistCreator
|
||||||
return Playlist(episodes, startIndex)
|
return Playlist(episodes, startIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create from a server playlist ID
|
||||||
|
*/
|
||||||
suspend fun createFromPlaylistId(
|
suspend fun createFromPlaylistId(
|
||||||
playlistId: UUID,
|
playlistId: UUID,
|
||||||
startIndex: Int?,
|
startIndex: Int?,
|
||||||
|
|
@ -114,7 +122,12 @@ class PlaylistCreator
|
||||||
req =
|
req =
|
||||||
GetItemsRequest(
|
GetItemsRequest(
|
||||||
parentId = item.id,
|
parentId = item.id,
|
||||||
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
|
enableImageTypes =
|
||||||
|
listOf(
|
||||||
|
ImageType.PRIMARY,
|
||||||
|
ImageType.THUMB,
|
||||||
|
ImageType.LOGO,
|
||||||
|
),
|
||||||
includeItemTypes = includeItemTypes,
|
includeItemTypes = includeItemTypes,
|
||||||
recursive = true,
|
recursive = true,
|
||||||
excludeItemIds = listOf(item.id),
|
excludeItemIds = listOf(item.id),
|
||||||
|
|
@ -133,6 +146,11 @@ class PlaylistCreator
|
||||||
return Playlist(items, 0)
|
return Playlist(items, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a [Playlist] contextually based on the given item.
|
||||||
|
*
|
||||||
|
* For example, an episode creates a queue of next up episodes in the series, or a movie creates one for its parts if needed
|
||||||
|
*/
|
||||||
suspend fun createFrom(
|
suspend fun createFrom(
|
||||||
item: BaseItemDto,
|
item: BaseItemDto,
|
||||||
startIndex: Int = 0,
|
startIndex: Int = 0,
|
||||||
|
|
@ -270,6 +288,9 @@ class PlaylistCreator
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the playlists on the server for a given media type
|
||||||
|
*/
|
||||||
suspend fun getServerPlaylists(
|
suspend fun getServerPlaylists(
|
||||||
mediaType: MediaType?,
|
mediaType: MediaType?,
|
||||||
scope: CoroutineScope,
|
scope: CoroutineScope,
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ import javax.inject.Singleton
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles determining whether the refresh rate and/or resolution of the display need to changed when playing media
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class RefreshRateService
|
class RefreshRateService
|
||||||
@Inject
|
@Inject
|
||||||
|
|
@ -119,6 +122,9 @@ class RefreshRateService
|
||||||
MainActivity.instance.changeDisplayMode(0)
|
MainActivity.instance.changeDisplayMode(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listens for the display to change so we known the refresh rate or resolution is updated
|
||||||
|
*/
|
||||||
private class Listener(
|
private class Listener(
|
||||||
val displayId: Int,
|
val displayId: Int,
|
||||||
) : DisplayManager.DisplayListener {
|
) : DisplayManager.DisplayListener {
|
||||||
|
|
@ -213,6 +219,9 @@ class RefreshRateService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper for a [Display.Mode]
|
||||||
|
*/
|
||||||
data class DisplayMode(
|
data class DisplayMode(
|
||||||
val modeId: Int,
|
val modeId: Int,
|
||||||
val physicalWidth: Int,
|
val physicalWidth: Int,
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,9 @@ import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the queue of items to show on the screensaver, both in-app or OS
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class ScreensaverService
|
class ScreensaverService
|
||||||
@Inject
|
@Inject
|
||||||
|
|
@ -67,6 +70,9 @@ class ScreensaverService
|
||||||
}.launchIn(scope)
|
}.launchIn(scope)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the timer before showing the in-app screensaver
|
||||||
|
*/
|
||||||
fun pulse() {
|
fun pulse() {
|
||||||
waitJob?.cancel()
|
waitJob?.cancel()
|
||||||
if (_state.value.enabled) {
|
if (_state.value.enabled) {
|
||||||
|
|
@ -95,6 +101,9 @@ class ScreensaverService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immediately start the in-app screensaver
|
||||||
|
*/
|
||||||
fun start() {
|
fun start() {
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
|
|
@ -104,6 +113,9 @@ class ScreensaverService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immediately stop the in-app screensaver
|
||||||
|
*/
|
||||||
fun stop(cancelJob: Boolean) {
|
fun stop(cancelJob: Boolean) {
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
|
|
@ -114,6 +126,9 @@ class ScreensaverService
|
||||||
if (cancelJob) waitJob?.cancel()
|
if (cancelJob) waitJob?.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signal to the OS for keeping the screen on such as during playback or when the in-app screensaver is active
|
||||||
|
*/
|
||||||
fun keepScreenOn(keep: Boolean) {
|
fun keepScreenOn(keep: Boolean) {
|
||||||
scope.launchDefault {
|
scope.launchDefault {
|
||||||
val screensaverEnabled = _state.value.enabled
|
val screensaverEnabled = _state.value.enabled
|
||||||
|
|
@ -136,6 +151,9 @@ class ScreensaverService
|
||||||
keepScreenOn.update { keep }
|
keepScreenOn.update { keep }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a flow of items to show on the screensaver
|
||||||
|
*/
|
||||||
fun createItemFlow(scope: CoroutineScope): Flow<CurrentItem?> =
|
fun createItemFlow(scope: CoroutineScope): Flow<CurrentItem?> =
|
||||||
flow {
|
flow {
|
||||||
val prefs =
|
val prefs =
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.github.damontecres.wholphin.services
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
|
import com.github.damontecres.wholphin.BuildConfig
|
||||||
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
|
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.setup.seerr.createSeerrApiUrl
|
import com.github.damontecres.wholphin.ui.setup.seerr.createSeerrApiUrl
|
||||||
|
|
@ -19,7 +20,7 @@ class SeerrApi(
|
||||||
)
|
)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
val active: Boolean get() = api.baseUrl.isNotNullOrBlank()
|
val active: Boolean get() = api.baseUrl.isNotNullOrBlank() && BuildConfig.DISCOVER_ENABLED
|
||||||
|
|
||||||
fun update(
|
fun update(
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import android.content.Context
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.lifecycle.asFlow
|
import androidx.lifecycle.asFlow
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.github.damontecres.wholphin.BuildConfig
|
||||||
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
|
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
|
||||||
import com.github.damontecres.wholphin.api.seerr.model.AuthJellyfinPostRequest
|
import com.github.damontecres.wholphin.api.seerr.model.AuthJellyfinPostRequest
|
||||||
import com.github.damontecres.wholphin.api.seerr.model.AuthLocalPostRequest
|
import com.github.damontecres.wholphin.api.seerr.model.AuthLocalPostRequest
|
||||||
|
|
@ -292,44 +293,46 @@ class UserSwitchListener
|
||||||
launchIO {
|
launchIO {
|
||||||
homeSettingsService.loadCurrentSettings(user.id)
|
homeSettingsService.loadCurrentSettings(user.id)
|
||||||
}
|
}
|
||||||
// Check for seerr server
|
if (BuildConfig.DISCOVER_ENABLED) {
|
||||||
launchIO {
|
// Check for seerr server
|
||||||
seerrServerDao
|
launchIO {
|
||||||
.getUsersByJellyfinUser(user.rowId)
|
seerrServerDao
|
||||||
.lastOrNull()
|
.getUsersByJellyfinUser(user.rowId)
|
||||||
?.let { seerrUser ->
|
.lastOrNull()
|
||||||
val server =
|
?.let { seerrUser ->
|
||||||
seerrServerDao.getServer(seerrUser.serverId)?.server
|
val server =
|
||||||
if (server != null) {
|
seerrServerDao.getServer(seerrUser.serverId)?.server
|
||||||
Timber.i("Found a seerr user & server")
|
if (server != null) {
|
||||||
try {
|
Timber.i("Found a seerr user & server")
|
||||||
seerrApi.update(server.url, seerrUser.credential)
|
try {
|
||||||
val userConfig =
|
seerrApi.update(server.url, seerrUser.credential)
|
||||||
if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
|
val userConfig =
|
||||||
login(
|
if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
|
||||||
seerrApi.api,
|
login(
|
||||||
seerrUser.authMethod,
|
seerrApi.api,
|
||||||
seerrUser.username,
|
seerrUser.authMethod,
|
||||||
seerrUser.password,
|
seerrUser.username,
|
||||||
)
|
seerrUser.password,
|
||||||
} else {
|
)
|
||||||
seerrApi.api.usersApi.authMeGet()
|
} else {
|
||||||
}
|
seerrApi.api.usersApi.authMeGet()
|
||||||
seerrServerRepository.set(
|
}
|
||||||
server,
|
seerrServerRepository.set(
|
||||||
seerrUser,
|
server,
|
||||||
userConfig,
|
seerrUser,
|
||||||
)
|
userConfig,
|
||||||
} catch (ex: Exception) {
|
)
|
||||||
Timber.w(
|
} catch (ex: Exception) {
|
||||||
ex,
|
Timber.w(
|
||||||
"Error logging into %s",
|
ex,
|
||||||
server.url,
|
"Error logging into %s",
|
||||||
)
|
server.url,
|
||||||
seerrServerRepository.error(server, seerrUser, ex)
|
)
|
||||||
|
seerrServerRepository.error(server, seerrUser, ex)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,18 @@ class SeerrService
|
||||||
return "${base}${prefix}$path"
|
return "${base}${prefix}$path"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun createDiscoverItem(item: Any): DiscoverItem =
|
||||||
|
when (item) {
|
||||||
|
is MovieResult -> createDiscoverItem(item)
|
||||||
|
is MovieDetails -> createDiscoverItem(item)
|
||||||
|
is TvResult -> createDiscoverItem(item)
|
||||||
|
is TvDetails -> createDiscoverItem(item)
|
||||||
|
is SeerrSearchResult -> createDiscoverItem(item)
|
||||||
|
is CreditCast -> createDiscoverItem(item)
|
||||||
|
is CreditCrew -> createDiscoverItem(item)
|
||||||
|
else -> throw IllegalArgumentException("Unsupported type ${item::class.qualifiedName}")
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun createDiscoverItem(movie: MovieResult): DiscoverItem =
|
suspend fun createDiscoverItem(movie: MovieResult): DiscoverItem =
|
||||||
DiscoverItem(
|
DiscoverItem(
|
||||||
id = movie.id,
|
id = movie.id,
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,9 @@ import org.jellyfin.sdk.model.api.MediaType
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listens for basic messages from the server such as messages
|
||||||
|
*/
|
||||||
@ActivityScoped
|
@ActivityScoped
|
||||||
class ServerEventListener
|
class ServerEventListener
|
||||||
@Inject
|
@Inject
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,9 @@ import javax.inject.Singleton
|
||||||
@Singleton
|
@Singleton
|
||||||
class SetupNavigationManager
|
class SetupNavigationManager
|
||||||
@Inject
|
@Inject
|
||||||
constructor() {
|
constructor(
|
||||||
|
private val navigationManager: NavigationManager,
|
||||||
|
) {
|
||||||
var backStack: MutableList<SetupDestination> = mutableStateListOf(SetupDestination.Loading)
|
var backStack: MutableList<SetupDestination> = mutableStateListOf(SetupDestination.Loading)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -25,6 +27,9 @@ class SetupNavigationManager
|
||||||
fun navigateTo(destination: SetupDestination) {
|
fun navigateTo(destination: SetupDestination) {
|
||||||
backStack[0] = destination
|
backStack[0] = destination
|
||||||
log()
|
log()
|
||||||
|
if (destination !is SetupDestination.AppContent) {
|
||||||
|
navigationManager.reloadHome()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun log() {
|
private fun log() {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,9 @@ import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manage the track choices for media
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class StreamChoiceService
|
class StreamChoiceService
|
||||||
@Inject
|
@Inject
|
||||||
|
|
@ -94,6 +97,9 @@ class StreamChoiceService
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the audio stream that should play
|
||||||
|
*/
|
||||||
suspend fun chooseAudioStream(
|
suspend fun chooseAudioStream(
|
||||||
source: MediaSourceInfo,
|
source: MediaSourceInfo,
|
||||||
seriesId: UUID?,
|
seriesId: UUID?,
|
||||||
|
|
@ -108,6 +114,9 @@ class StreamChoiceService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the audio stream that should play
|
||||||
|
*/
|
||||||
fun chooseAudioStream(
|
fun chooseAudioStream(
|
||||||
candidates: List<MediaStream>,
|
candidates: List<MediaStream>,
|
||||||
itemPlayback: ItemPlayback?,
|
itemPlayback: ItemPlayback?,
|
||||||
|
|
@ -135,6 +144,9 @@ class StreamChoiceService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the subtitle stream that should play
|
||||||
|
*/
|
||||||
suspend fun chooseSubtitleStream(
|
suspend fun chooseSubtitleStream(
|
||||||
source: MediaSourceInfo,
|
source: MediaSourceInfo,
|
||||||
audioStream: MediaStream?,
|
audioStream: MediaStream?,
|
||||||
|
|
@ -196,6 +208,9 @@ class StreamChoiceService
|
||||||
)?.index
|
)?.index
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the subtitle stream that should play
|
||||||
|
*/
|
||||||
fun chooseSubtitleStream(
|
fun chooseSubtitleStream(
|
||||||
audioStreamLang: String?,
|
audioStreamLang: String?,
|
||||||
candidates: List<MediaStream>,
|
candidates: List<MediaStream>,
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets trailers for media
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class TrailerService
|
class TrailerService
|
||||||
@Inject
|
@Inject
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,9 @@ import javax.inject.Singleton
|
||||||
import kotlin.time.Duration.Companion.hours
|
import kotlin.time.Duration.Companion.hours
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an app update is available
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class UpdateChecker
|
class UpdateChecker
|
||||||
@Inject
|
@Inject
|
||||||
|
|
@ -62,9 +65,14 @@ class UpdateChecker
|
||||||
|
|
||||||
private val NOTE_REGEX = Regex("<!-- app-note:(.+) -->")
|
private val NOTE_REGEX = Regex("<!-- app-note:(.+) -->")
|
||||||
|
|
||||||
val ACTIVE = true
|
val ACTIVE = BuildConfig.UPDATING_ENABLED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the app hasn't recently checked, check for any updates and if there is one, show a toast message
|
||||||
|
*
|
||||||
|
* This is safe to call many times because it will only show the toast at most once every 12 hours
|
||||||
|
*/
|
||||||
suspend fun maybeShowUpdateToast(
|
suspend fun maybeShowUpdateToast(
|
||||||
updateUrl: String,
|
updateUrl: String,
|
||||||
showNegativeToast: Boolean = false,
|
showNegativeToast: Boolean = false,
|
||||||
|
|
@ -112,55 +120,81 @@ class UpdateChecker
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the currently installed version
|
||||||
|
*/
|
||||||
fun getInstalledVersion(): Version {
|
fun getInstalledVersion(): Version {
|
||||||
val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0)
|
val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||||
return Version.Companion.fromString(pkgInfo.versionName!!)
|
return Version.fromString(pkgInfo.versionName!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getLatestRelease(updateUrl: String): Release? {
|
suspend fun getRelease(version: Version): Release? {
|
||||||
|
val url =
|
||||||
|
"https://api.github.com/repos/damontecres/Wholphin/releases/tags/v${version.major}.${version.minor}.${version.patch}"
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
|
val request =
|
||||||
|
Request
|
||||||
|
.Builder()
|
||||||
|
.url(url)
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
getRelease(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the latest released version
|
||||||
|
*/
|
||||||
|
suspend fun getLatestRelease(updateUrl: String): Release? =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
val request =
|
val request =
|
||||||
Request
|
Request
|
||||||
.Builder()
|
.Builder()
|
||||||
.url(updateUrl)
|
.url(updateUrl)
|
||||||
.get()
|
.get()
|
||||||
.build()
|
.build()
|
||||||
okHttpClient.newCall(request).execute().use {
|
getRelease(request)
|
||||||
if (it.isSuccessful && it.body != null) {
|
}
|
||||||
val result = Json.parseToJsonElement(it.body!!.string())
|
|
||||||
val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull
|
private fun getRelease(request: Request): Release? {
|
||||||
val version = Version.tryFromString(name)
|
return okHttpClient.newCall(request).execute().use {
|
||||||
val publishedAt =
|
if (it.isSuccessful && it.body != null) {
|
||||||
result.jsonObject["published_at"]?.jsonPrimitive?.contentOrNull
|
val result = Json.parseToJsonElement(it.body!!.string())
|
||||||
val body = result.jsonObject["body"]?.jsonPrimitive?.contentOrNull
|
val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull
|
||||||
val downloadUrl =
|
val version = Version.tryFromString(name)
|
||||||
result.jsonObject["assets"]
|
val publishedAt =
|
||||||
?.jsonArray
|
result.jsonObject["published_at"]?.jsonPrimitive?.contentOrNull
|
||||||
?.let { assets -> getDownloadUrl(assets, BuildConfig.DEBUG) }
|
val body = result.jsonObject["body"]?.jsonPrimitive?.contentOrNull
|
||||||
Timber.v("version=$version, downloadUrl=$downloadUrl")
|
val downloadUrl =
|
||||||
if (version != null) {
|
result.jsonObject["assets"]
|
||||||
val notes =
|
?.jsonArray
|
||||||
if (body.isNotNullOrBlank()) {
|
?.let { assets -> getDownloadUrl(assets, BuildConfig.DEBUG) }
|
||||||
NOTE_REGEX
|
Timber.v("version=$version, downloadUrl=$downloadUrl")
|
||||||
.findAll(body)
|
if (version != null) {
|
||||||
.map { m ->
|
val notes =
|
||||||
m.groupValues[1]
|
if (body.isNotNullOrBlank()) {
|
||||||
}.toList()
|
NOTE_REGEX
|
||||||
} else {
|
.findAll(body)
|
||||||
listOf()
|
.map { m ->
|
||||||
}
|
m.groupValues[1]
|
||||||
return@use Release(version, downloadUrl, publishedAt, body, notes)
|
}.toList()
|
||||||
} else {
|
} else {
|
||||||
Timber.w("Update version parsing failed. name=$name")
|
emptyList()
|
||||||
}
|
}
|
||||||
|
return@use Release(version, downloadUrl, publishedAt, body, notes)
|
||||||
} else {
|
} else {
|
||||||
Timber.w("Update check failed: ${it.message}")
|
Timber.w("Update version parsing failed. name=$name")
|
||||||
}
|
}
|
||||||
return@use null
|
} else {
|
||||||
|
Timber.w("Update check failed ${it.code}: ${it.message}")
|
||||||
}
|
}
|
||||||
|
return@use null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download and install an update
|
||||||
|
*/
|
||||||
suspend fun installRelease(
|
suspend fun installRelease(
|
||||||
release: Release,
|
release: Release,
|
||||||
callback: DownloadCallback,
|
callback: DownloadCallback,
|
||||||
|
|
@ -263,6 +297,9 @@ class UpdateChecker
|
||||||
return targetFile
|
return targetFile
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the app has permission to write the download
|
||||||
|
*/
|
||||||
fun hasPermissions(): Boolean =
|
fun hasPermissions(): Boolean =
|
||||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
|
||||||
(
|
(
|
||||||
|
|
@ -327,7 +364,19 @@ data class Release(
|
||||||
val publishedAt: String?,
|
val publishedAt: String?,
|
||||||
val body: String?,
|
val body: String?,
|
||||||
val notes: List<String>,
|
val notes: List<String>,
|
||||||
)
|
) {
|
||||||
|
val content =
|
||||||
|
"# ${version}\n" +
|
||||||
|
(
|
||||||
|
(notes.joinToString("\n").takeIf { it.isNotNullOrBlank() } ?: "") +
|
||||||
|
(body ?: "")
|
||||||
|
).replace(
|
||||||
|
Regex("https://github.com/\\w*/\\w+/pull/(\\d+)"),
|
||||||
|
"#$1",
|
||||||
|
)
|
||||||
|
// Remove the last line for full changelog since its just a link
|
||||||
|
.replace(Regex("\\*\\*Full Changelog\\*\\*.*"), "")
|
||||||
|
}
|
||||||
|
|
||||||
interface DownloadCallback {
|
interface DownloadCallback {
|
||||||
fun contentLength(contentLength: Long)
|
fun contentLength(contentLength: Long)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ import kotlinx.coroutines.flow.map
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current user's [UserPreferences]
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class UserPreferencesService
|
class UserPreferencesService
|
||||||
@Inject
|
@Inject
|
||||||
|
|
|
||||||
|
|
@ -34,26 +34,48 @@ import org.jellyfin.sdk.model.DeviceInfo
|
||||||
import javax.inject.Qualifier
|
import javax.inject.Qualifier
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An [OkHttpClient] that includes the user's access token when making requests
|
||||||
|
*/
|
||||||
@Qualifier
|
@Qualifier
|
||||||
@Retention(AnnotationRetention.BINARY)
|
@Retention(AnnotationRetention.BINARY)
|
||||||
annotation class AuthOkHttpClient
|
annotation class AuthOkHttpClient
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A basic [OkHttpClient] that does not include auth
|
||||||
|
*/
|
||||||
@Qualifier
|
@Qualifier
|
||||||
@Retention(AnnotationRetention.BINARY)
|
@Retention(AnnotationRetention.BINARY)
|
||||||
annotation class StandardOkHttpClient
|
annotation class StandardOkHttpClient
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [CoroutineScope] with [Dispatchers.IO]
|
||||||
|
*/
|
||||||
@Qualifier
|
@Qualifier
|
||||||
@Retention(AnnotationRetention.BINARY)
|
@Retention(AnnotationRetention.BINARY)
|
||||||
annotation class IoCoroutineScope
|
annotation class IoCoroutineScope
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [CoroutineScope] with [Dispatchers.Default]
|
||||||
|
*/
|
||||||
@Qualifier
|
@Qualifier
|
||||||
@Retention(AnnotationRetention.BINARY)
|
@Retention(AnnotationRetention.BINARY)
|
||||||
annotation class DefaultCoroutineScope
|
annotation class DefaultCoroutineScope
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Dispatchers.IO]
|
||||||
|
*
|
||||||
|
* @see IoCoroutineScope
|
||||||
|
*/
|
||||||
@Qualifier
|
@Qualifier
|
||||||
@Retention(AnnotationRetention.BINARY)
|
@Retention(AnnotationRetention.BINARY)
|
||||||
annotation class IoDispatcher
|
annotation class IoDispatcher
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Dispatchers.Default]
|
||||||
|
*
|
||||||
|
* @see DefaultCoroutineScope
|
||||||
|
*/
|
||||||
@Qualifier
|
@Qualifier
|
||||||
@Retention(AnnotationRetention.BINARY)
|
@Retention(AnnotationRetention.BINARY)
|
||||||
annotation class DefaultDispatcher
|
annotation class DefaultDispatcher
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@ import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.core.DataStoreFactory
|
import androidx.datastore.core.DataStoreFactory
|
||||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||||
import androidx.datastore.dataStoreFile
|
import androidx.datastore.dataStoreFile
|
||||||
|
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.preferencesDataStoreFile
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import com.github.damontecres.wholphin.data.AppDatabase
|
import com.github.damontecres.wholphin.data.AppDatabase
|
||||||
import com.github.damontecres.wholphin.data.ItemPlaybackDao
|
import com.github.damontecres.wholphin.data.ItemPlaybackDao
|
||||||
|
|
@ -85,4 +88,13 @@ object DatabaseModule {
|
||||||
produceNewData = { AppPreferences.getDefaultInstance() },
|
produceNewData = { AppPreferences.getDefaultInstance() },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun keyValueDataStore(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
): DataStore<Preferences> =
|
||||||
|
PreferenceDataStoreFactory.create(
|
||||||
|
produceFile = { context.preferencesDataStoreFile("key_value") },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,9 @@ import kotlin.time.Duration.Companion.minutes
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
import kotlin.time.toJavaDuration
|
import kotlin.time.toJavaDuration
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules the [TvProviderWorker] to update the OS resume watching row
|
||||||
|
*/
|
||||||
@ActivityScoped
|
@ActivityScoped
|
||||||
class TvProviderSchedulerService
|
class TvProviderSchedulerService
|
||||||
@Inject
|
@Inject
|
||||||
|
|
@ -44,6 +47,7 @@ class TvProviderSchedulerService
|
||||||
workManager.cancelUniqueWork(TvProviderWorker.WORK_NAME)
|
workManager.cancelUniqueWork(TvProviderWorker.WORK_NAME)
|
||||||
if (supportsTvProvider) {
|
if (supportsTvProvider) {
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
|
// Schedule a new worker whenever the user changes
|
||||||
activity.lifecycleScope.launchIO(ExceptionHandler()) {
|
activity.lifecycleScope.launchIO(ExceptionHandler()) {
|
||||||
Timber.i("Scheduling TvProviderWorker for ${user.user}")
|
Timber.i("Scheduling TvProviderWorker for ${user.user}")
|
||||||
workManager
|
workManager
|
||||||
|
|
@ -70,6 +74,9 @@ class TvProviderSchedulerService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the [TvProviderWorker] as a one-off instead of scheduled
|
||||||
|
*/
|
||||||
fun launchOneTimeRefresh() {
|
fun launchOneTimeRefresh() {
|
||||||
if (supportsTvProvider) {
|
if (supportsTvProvider) {
|
||||||
activity.lifecycleScope.launchIO(ExceptionHandler()) {
|
activity.lifecycleScope.launchIO(ExceptionHandler()) {
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,11 @@ import java.util.Date
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.time.Duration.Companion.minutes
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the Android OS resume watching row for a given Jellyfin user
|
||||||
|
*
|
||||||
|
* @see TvProviderSchedulerService
|
||||||
|
*/
|
||||||
@HiltWorker
|
@HiltWorker
|
||||||
class TvProviderWorker
|
class TvProviderWorker
|
||||||
@AssistedInject
|
@AssistedInject
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ class CoilTrickplayTransformation(
|
||||||
val index: Int,
|
val index: Int,
|
||||||
) : Transformation() {
|
) : Transformation() {
|
||||||
private val x: Int = imageIndex % numColumns
|
private val x: Int = imageIndex % numColumns
|
||||||
private val y: Int = imageIndex / numRows
|
private val y: Int = imageIndex / numColumns
|
||||||
|
|
||||||
override val cacheKey: String
|
override val cacheKey: String
|
||||||
get() = "CoilTrickplayTransformation_$index,$x,$y"
|
get() = "CoilTrickplayTransformation_$index,$x,$y"
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,9 @@ import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.CoroutineStart
|
import kotlinx.coroutines.CoroutineStart
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.acra.ACRA
|
import org.acra.ACRA
|
||||||
|
|
@ -434,3 +437,18 @@ fun Response<BaseItemDtoQueryResult>.toBaseItems(
|
||||||
fun Int?.gt(that: Int) = (this ?: 0) > that
|
fun Int?.gt(that: Int) = (this ?: 0) > that
|
||||||
|
|
||||||
fun Int?.lt(that: Int) = (this ?: 0) < that
|
fun Int?.lt(that: Int) = (this ?: 0) < that
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simplifies endlessly collecting a flow
|
||||||
|
*/
|
||||||
|
fun <T> Flow<T>.collectLatestIn(
|
||||||
|
scope: CoroutineScope,
|
||||||
|
action: suspend (value: T) -> Unit,
|
||||||
|
) {
|
||||||
|
scope.launchDefault { this@collectLatestIn.collectLatest(action) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Easy way to combine two flows into a [Pair]
|
||||||
|
*/
|
||||||
|
fun <T1, T2> Flow<T1>.combinePair(flow: Flow<T2>): Flow<Pair<T1, T2>> = combine(flow) { t1, t2 -> Pair(t1, t2) }
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.WholphinApplication
|
import com.github.damontecres.wholphin.WholphinApplication
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.MediaSegmentType
|
import org.jellyfin.sdk.model.api.MediaSegmentType
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
|
|
@ -16,8 +17,25 @@ import java.time.format.DateTimeParseException
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
val TimeFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
private var timeFormatter: DateTimeFormatter =
|
||||||
val DateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("MMM d, yyyy")
|
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(Locale.getDefault())
|
||||||
|
|
||||||
|
fun getTimeFormatter(): DateTimeFormatter {
|
||||||
|
if (timeFormatter.locale != Locale.getDefault()) {
|
||||||
|
timeFormatter = timeFormatter.withLocale(Locale.getDefault())
|
||||||
|
}
|
||||||
|
return timeFormatter
|
||||||
|
}
|
||||||
|
|
||||||
|
private var dateFormatter: DateTimeFormatter =
|
||||||
|
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(Locale.getDefault())
|
||||||
|
|
||||||
|
fun getDateFormatter(): DateTimeFormatter {
|
||||||
|
if (dateFormatter.locale != Locale.getDefault()) {
|
||||||
|
dateFormatter = dateFormatter.withLocale(Locale.getDefault())
|
||||||
|
}
|
||||||
|
return dateFormatter
|
||||||
|
}
|
||||||
|
|
||||||
// TODO server returns in UTC, but sdk converts to local time
|
// TODO server returns in UTC, but sdk converts to local time
|
||||||
// eg 2020-02-14T00:00:00.0000000Z => 2020-02-13T17:00:00 PT => Feb 13, 2020
|
// eg 2020-02-14T00:00:00.0000000Z => 2020-02-13T17:00:00 PT => Feb 13, 2020
|
||||||
|
|
@ -25,9 +43,11 @@ val DateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("MMM d, yyyy"
|
||||||
/**
|
/**
|
||||||
* Format a [LocalDateTime] as `Aug 24, 2000`
|
* Format a [LocalDateTime] as `Aug 24, 2000`
|
||||||
*/
|
*/
|
||||||
fun formatDateTime(dateTime: LocalDateTime): String = DateFormatter.format(dateTime)
|
fun formatDateTime(dateTime: LocalDateTime): String = getDateFormatter().format(dateTime)
|
||||||
|
|
||||||
fun formatDate(dateTime: LocalDate): String = DateFormatter.format(dateTime)
|
fun formatDate(dateTime: LocalDate): String = getDateFormatter().format(dateTime)
|
||||||
|
|
||||||
|
fun formatDate(dateTime: LocalDateTime): String = getDateFormatter().format(dateTime)
|
||||||
|
|
||||||
fun toLocalDate(date: String?): LocalDate? =
|
fun toLocalDate(date: String?): LocalDate? =
|
||||||
date?.let {
|
date?.let {
|
||||||
|
|
@ -109,30 +129,25 @@ fun abbreviateNumber(number: Int): String {
|
||||||
return String.format(Locale.getDefault(), "%.1f%s", count, abbrevSuffixes[unit])
|
return String.format(Locale.getDefault(), "%.1f%s", count, abbrevSuffixes[unit])
|
||||||
}
|
}
|
||||||
|
|
||||||
val byteSuffixes = listOf("B", "KB", "MB", "GB", "TB")
|
val byteSuffixes = listOf("B", "KiB", "MiB", "GiB", "TiB")
|
||||||
val byteRateSuffixes = listOf("bps", "kbps", "mbps", "gbps", "tbps")
|
val byteRateSuffixes = listOf("bps", "kbps", "mbps", "gbps", "tbps")
|
||||||
|
|
||||||
/**
|
|
||||||
* Format bytes
|
|
||||||
*/
|
|
||||||
fun formatBytes(
|
|
||||||
bytes: Int,
|
|
||||||
suffixes: List<String> = byteSuffixes,
|
|
||||||
) = formatBytes(bytes.toLong(), suffixes)
|
|
||||||
|
|
||||||
fun formatBytes(
|
fun formatBytes(
|
||||||
bytes: Long,
|
bytes: Long,
|
||||||
suffixes: List<String> = byteSuffixes,
|
suffixes: List<String> = byteSuffixes,
|
||||||
|
divisor: Int = 1024,
|
||||||
): String {
|
): String {
|
||||||
var unit = 0
|
var unit = 0
|
||||||
var count = bytes.toDouble()
|
var count = bytes.toDouble()
|
||||||
while (count >= 1024 && unit + 1 < suffixes.size) {
|
while (count >= divisor && unit + 1 < suffixes.size) {
|
||||||
count /= 1024
|
count /= divisor
|
||||||
unit++
|
unit++
|
||||||
}
|
}
|
||||||
return String.format(Locale.getDefault(), "%.2f%s", count, suffixes[unit])
|
return String.format(Locale.getDefault(), "%.2f %s", count, suffixes[unit])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun formatBitrate(bitrate: Int) = formatBytes(bitrate.toLong(), byteRateSuffixes, 1000)
|
||||||
|
|
||||||
@get:StringRes
|
@get:StringRes
|
||||||
val MediaSegmentType.stringRes: Int
|
val MediaSegmentType.stringRes: Int
|
||||||
get() =
|
get() =
|
||||||
|
|
@ -184,3 +199,45 @@ fun listToDotString(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@StringRes
|
||||||
|
fun formatTypeName(type: BaseItemKind): Int =
|
||||||
|
when (type) {
|
||||||
|
BaseItemKind.MOVIE -> R.string.movies
|
||||||
|
BaseItemKind.SERIES -> R.string.tv_shows
|
||||||
|
BaseItemKind.EPISODE -> R.string.episodes
|
||||||
|
BaseItemKind.VIDEO -> R.string.videos
|
||||||
|
BaseItemKind.PLAYLIST -> R.string.playlists
|
||||||
|
BaseItemKind.PERSON -> R.string.people
|
||||||
|
BaseItemKind.BOX_SET -> R.string.collections
|
||||||
|
BaseItemKind.AUDIO -> TODO()
|
||||||
|
BaseItemKind.CHANNEL -> R.string.channels
|
||||||
|
BaseItemKind.GENRE -> R.string.genres
|
||||||
|
BaseItemKind.LIVE_TV_CHANNEL -> R.string.channels
|
||||||
|
BaseItemKind.MUSIC_ALBUM -> TODO()
|
||||||
|
BaseItemKind.MUSIC_ARTIST -> TODO()
|
||||||
|
BaseItemKind.MUSIC_GENRE -> TODO()
|
||||||
|
BaseItemKind.MUSIC_VIDEO -> TODO()
|
||||||
|
BaseItemKind.PHOTO -> R.string.photos
|
||||||
|
BaseItemKind.PHOTO_ALBUM -> TODO()
|
||||||
|
BaseItemKind.PROGRAM -> TODO()
|
||||||
|
BaseItemKind.RECORDING -> TODO()
|
||||||
|
BaseItemKind.SEASON -> R.string.tv_seasons
|
||||||
|
BaseItemKind.STUDIO -> R.string.studios
|
||||||
|
BaseItemKind.TRAILER -> R.string.trailers
|
||||||
|
BaseItemKind.TV_CHANNEL -> R.string.channels
|
||||||
|
BaseItemKind.TV_PROGRAM -> TODO()
|
||||||
|
BaseItemKind.USER_ROOT_FOLDER -> TODO()
|
||||||
|
BaseItemKind.USER_VIEW -> TODO()
|
||||||
|
BaseItemKind.YEAR -> TODO()
|
||||||
|
BaseItemKind.AGGREGATE_FOLDER -> TODO()
|
||||||
|
BaseItemKind.AUDIO_BOOK -> TODO()
|
||||||
|
BaseItemKind.BASE_PLUGIN_FOLDER -> TODO()
|
||||||
|
BaseItemKind.BOOK -> TODO()
|
||||||
|
BaseItemKind.CHANNEL_FOLDER_ITEM -> TODO()
|
||||||
|
BaseItemKind.COLLECTION_FOLDER -> TODO()
|
||||||
|
BaseItemKind.FOLDER -> TODO()
|
||||||
|
BaseItemKind.MANUAL_PLAYLISTS_FOLDER -> TODO()
|
||||||
|
BaseItemKind.LIVE_TV_PROGRAM -> TODO()
|
||||||
|
BaseItemKind.PLAYLISTS_FOLDER -> TODO()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -98,10 +99,15 @@ fun BannerCard(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var imageError by remember(imageUrl) { mutableStateOf(false) }
|
var imageError by remember(imageUrl) { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Stabilize callbacks to prevent AsyncImage from recomposing
|
||||||
|
val currentOnClick by rememberUpdatedState(onClick)
|
||||||
|
val currentOnLongClick by rememberUpdatedState(onLongClick)
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = modifier.size(cardHeight * aspectRatio, cardHeight),
|
modifier = modifier.size(cardHeight * aspectRatio, cardHeight),
|
||||||
onClick = onClick,
|
onClick = { currentOnClick() },
|
||||||
onLongClick = onLongClick,
|
onLongClick = { currentOnLongClick() },
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
colors =
|
colors =
|
||||||
CardDefaults.colors(
|
CardDefaults.colors(
|
||||||
|
|
@ -119,7 +125,7 @@ fun BannerCard(
|
||||||
model = imageUrl,
|
model = imageUrl,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
contentScale = imageContentScale,
|
contentScale = imageContentScale,
|
||||||
onError = { imageError = true },
|
onError = remember { { imageError = true } },
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -214,7 +220,7 @@ fun BannerCardWithTitle(
|
||||||
) {
|
) {
|
||||||
val focused by interactionSource.collectIsFocusedAsState()
|
val focused by interactionSource.collectIsFocusedAsState()
|
||||||
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
|
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
|
||||||
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
|
val spaceBelow by animateDpAsState(if (focused) 0.dp else 8.dp)
|
||||||
val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
|
val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
|
||||||
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
|
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
|
||||||
val width = cardHeight * aspectRationToUse
|
val width = cardHeight * aspectRationToUse
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowForward
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.NonRestartableComposable
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
|
@ -34,8 +35,10 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.tv.material3.Card
|
import androidx.tv.material3.Card
|
||||||
import androidx.tv.material3.CardDefaults
|
import androidx.tv.material3.CardDefaults
|
||||||
|
import androidx.tv.material3.Icon
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
|
import androidx.tv.material3.surfaceColorAtElevation
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||||
import com.github.damontecres.wholphin.data.model.SeerrAvailability
|
import com.github.damontecres.wholphin.data.model.SeerrAvailability
|
||||||
|
|
@ -50,14 +53,14 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@NonRestartableComposable
|
|
||||||
fun DiscoverItemCard(
|
fun DiscoverItemCard(
|
||||||
item: DiscoverItem?,
|
item: DiscoverItem?,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
showOverlay: Boolean,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
showOverlay: Boolean = true,
|
||||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
width: Dp = Cards.height2x3 * AspectRatios.TALL,
|
||||||
) {
|
) {
|
||||||
val focused by interactionSource.collectIsFocusedAsState()
|
val focused by interactionSource.collectIsFocusedAsState()
|
||||||
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
|
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
|
||||||
|
|
@ -77,16 +80,15 @@ fun DiscoverItemCard(
|
||||||
} else {
|
} else {
|
||||||
focusedAfterDelay = false
|
focusedAfterDelay = false
|
||||||
}
|
}
|
||||||
val width = Cards.height2x3 * AspectRatios.TALL
|
|
||||||
val height = Dp.Unspecified * (1f / AspectRatios.TALL)
|
|
||||||
Column(
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(spaceBetween),
|
verticalArrangement = Arrangement.spacedBy(spaceBetween),
|
||||||
modifier = modifier.size(width, height),
|
modifier = modifier.size(width, Dp.Unspecified),
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.size(Dp.Unspecified, Cards.height2x3)
|
.size(width, Dp.Unspecified)
|
||||||
.aspectRatio(AspectRatios.TALL),
|
.aspectRatio(AspectRatios.TALL),
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
|
|
@ -286,6 +288,88 @@ fun PartiallyAvailableIndicator(modifier: Modifier = Modifier) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DiscoverViewMoreCard(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
) {
|
||||||
|
val focused by interactionSource.collectIsFocusedAsState()
|
||||||
|
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
|
||||||
|
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
|
||||||
|
var focusedAfterDelay by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val hideOverlayDelay = 500L
|
||||||
|
if (focused) {
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
delay(hideOverlayDelay)
|
||||||
|
if (focused) {
|
||||||
|
focusedAfterDelay = true
|
||||||
|
} else {
|
||||||
|
focusedAfterDelay = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
focusedAfterDelay = false
|
||||||
|
}
|
||||||
|
val width = Cards.height2x3 * AspectRatios.TALL
|
||||||
|
val height = Dp.Unspecified * (1f / AspectRatios.TALL)
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(spaceBetween),
|
||||||
|
modifier = modifier.size(width, height),
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.size(Dp.Unspecified, Cards.height2x3)
|
||||||
|
.aspectRatio(AspectRatios.TALL),
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
colors =
|
||||||
|
CardDefaults.colors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ArrowForward,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface,
|
||||||
|
contentDescription = "View more",
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(bottom = spaceBelow)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.view_more),
|
||||||
|
maxLines = 1,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 4.dp)
|
||||||
|
.enableMarquee(focusedAfterDelay),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@PreviewTvSpec
|
@PreviewTvSpec
|
||||||
@Composable
|
@Composable
|
||||||
private fun Preview() {
|
private fun Preview() {
|
||||||
|
|
@ -294,6 +378,11 @@ private fun Preview() {
|
||||||
PendingIndicator()
|
PendingIndicator()
|
||||||
AvailableIndicator()
|
AvailableIndicator()
|
||||||
PartiallyAvailableIndicator()
|
PartiallyAvailableIndicator()
|
||||||
|
DiscoverViewMoreCard(
|
||||||
|
onClick = {},
|
||||||
|
onLongClick = {},
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ fun EpisodeCard(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
imageHeight: Dp = Dp.Unspecified,
|
imageHeight: Dp = Dp.Unspecified,
|
||||||
imageWidth: Dp = Dp.Unspecified,
|
imageWidth: Dp = Dp.Unspecified,
|
||||||
|
showImageOverlay: Boolean = false,
|
||||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
) {
|
) {
|
||||||
val dto = item?.data
|
val dto = item?.data
|
||||||
|
|
@ -94,7 +95,7 @@ fun EpisodeCard(
|
||||||
ItemCardImage(
|
ItemCardImage(
|
||||||
item = item,
|
item = item,
|
||||||
name = item?.name,
|
name = item?.name,
|
||||||
showOverlay = false,
|
showOverlay = showImageOverlay,
|
||||||
favorite = dto?.userData?.isFavorite ?: false,
|
favorite = dto?.userData?.isFavorite ?: false,
|
||||||
watched = dto?.userData?.played ?: false,
|
watched = dto?.userData?.played ?: false,
|
||||||
unwatchedCount = dto?.userData?.unplayedItemCount ?: -1,
|
unwatchedCount = dto?.userData?.unplayedItemCount ?: -1,
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,10 @@ import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.NonRestartableComposable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
|
@ -45,6 +47,10 @@ fun <T> ItemRow(
|
||||||
val firstFocus = remember { FocusRequester() }
|
val firstFocus = remember { FocusRequester() }
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
var position by rememberInt()
|
var position by rememberInt()
|
||||||
|
|
||||||
|
val currentOnClickItem by rememberUpdatedState(onClickItem)
|
||||||
|
val currentOnLongClickItem by rememberUpdatedState(onLongClickItem)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier =
|
modifier =
|
||||||
|
|
@ -54,12 +60,8 @@ fun <T> ItemRow(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Text(
|
ItemRowTitle(title)
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
|
||||||
modifier = Modifier.padding(start = 8.dp),
|
|
||||||
)
|
|
||||||
LazyRow(
|
LazyRow(
|
||||||
state = state,
|
state = state,
|
||||||
horizontalArrangement = Arrangement.spacedBy(horizontalPadding),
|
horizontalArrangement = Arrangement.spacedBy(horizontalPadding),
|
||||||
|
|
@ -73,25 +75,50 @@ fun <T> ItemRow(
|
||||||
) {
|
) {
|
||||||
itemsIndexed(items) { index, item ->
|
itemsIndexed(items) { index, item ->
|
||||||
val cardModifier =
|
val cardModifier =
|
||||||
if (index == position) {
|
remember(index, position) {
|
||||||
Modifier.focusRequester(firstFocus)
|
if (index == position) {
|
||||||
} else {
|
Modifier.focusRequester(firstFocus)
|
||||||
Modifier
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val onClick =
|
||||||
|
remember(index, item) {
|
||||||
|
{
|
||||||
|
position = index
|
||||||
|
if (item != null) currentOnClickItem(index, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val onLongClick =
|
||||||
|
remember(index, item) {
|
||||||
|
{
|
||||||
|
position = index
|
||||||
|
if (item != null) currentOnLongClickItem(index, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cardContent.invoke(
|
cardContent.invoke(
|
||||||
index,
|
index,
|
||||||
item,
|
item,
|
||||||
cardModifier,
|
cardModifier,
|
||||||
{
|
onClick,
|
||||||
position = index
|
onLongClick,
|
||||||
if (item != null) onClickItem.invoke(index, item)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
position = index
|
|
||||||
if (item != null) onLongClickItem.invoke(index, item)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@NonRestartableComposable
|
||||||
|
fun ItemRowTitle(
|
||||||
|
title: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) = Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
modifier = modifier.padding(start = 8.dp),
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
|
@ -25,9 +26,11 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.layout.layout
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.tv.material3.Border
|
import androidx.tv.material3.Border
|
||||||
|
|
@ -80,7 +83,7 @@ fun PersonCard(
|
||||||
) {
|
) {
|
||||||
val hideOverlayDelay = 1_000L
|
val hideOverlayDelay = 1_000L
|
||||||
|
|
||||||
val focused = interactionSource.collectIsFocusedAsState().value
|
val focused by interactionSource.collectIsFocusedAsState()
|
||||||
var focusedAfterDelay by remember { mutableStateOf(false) }
|
var focusedAfterDelay by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
if (focused) {
|
if (focused) {
|
||||||
|
|
@ -95,10 +98,13 @@ fun PersonCard(
|
||||||
} else {
|
} else {
|
||||||
focusedAfterDelay = false
|
focusedAfterDelay = false
|
||||||
}
|
}
|
||||||
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
|
|
||||||
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
|
// Do not use `by` here, this way we are Defer reads and recompositions to only when modifier calculates
|
||||||
|
val spaceBetweenState = animateDpAsState(if (focused) 12.dp else 4.dp, label = "spaceBetween")
|
||||||
|
val spaceBelowState = animateDpAsState(if (focused) 4.dp else 12.dp, label = "spaceBelow")
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(spaceBetween),
|
verticalArrangement = Arrangement.spacedBy(4.dp), // Fixed base spacing
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
|
|
@ -168,8 +174,16 @@ fun PersonCard(
|
||||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.padding(bottom = spaceBelow)
|
// Optimization: move animation reads to layout/draw phase
|
||||||
.fillMaxWidth(),
|
.offset {
|
||||||
|
IntOffset(0, (spaceBetweenState.value - 4.dp).roundToPx())
|
||||||
|
}.layout { measurable, constraints ->
|
||||||
|
val paddingPx = spaceBelowState.value.roundToPx()
|
||||||
|
val placeable = measurable.measure(constraints)
|
||||||
|
layout(placeable.width, placeable.height + paddingPx) {
|
||||||
|
placeable.placeRelative(0, 0)
|
||||||
|
}
|
||||||
|
}.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = name ?: "",
|
text = name ?: "",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
@ -18,10 +19,12 @@ import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.layout
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.material3.Card
|
import androidx.tv.material3.Card
|
||||||
import androidx.tv.material3.CardDefaults
|
import androidx.tv.material3.CardDefaults
|
||||||
|
|
@ -52,7 +55,7 @@ fun SeasonCard(
|
||||||
val imageUrlService = LocalImageUrlService.current
|
val imageUrlService = LocalImageUrlService.current
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val imageUrl =
|
val imageUrl =
|
||||||
remember(item, imageHeight, imageWidth) {
|
remember(item, imageHeight, imageWidth, density) {
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
val fillHeight =
|
val fillHeight =
|
||||||
if (imageHeight != Dp.Unspecified) {
|
if (imageHeight != Dp.Unspecified) {
|
||||||
|
|
@ -125,14 +128,17 @@ fun SeasonCard(
|
||||||
aspectRatio: Float = AspectRatios.TALL,
|
aspectRatio: Float = AspectRatios.TALL,
|
||||||
) {
|
) {
|
||||||
val focused by interactionSource.collectIsFocusedAsState()
|
val focused by interactionSource.collectIsFocusedAsState()
|
||||||
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
|
// Do not use `by` here, this way we are Defer reads and recompositions to only when modifier calculates
|
||||||
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
|
val spaceBetween = animateDpAsState(if (focused) 12.dp else 4.dp, label = "spaceBetween")
|
||||||
|
val spaceBelow = animateDpAsState(if (focused) 4.dp else 12.dp, label = "spaceBelow")
|
||||||
|
|
||||||
val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
|
val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
|
||||||
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
|
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
|
||||||
val width = imageHeight * aspectRationToUse
|
val width = imageHeight * aspectRationToUse
|
||||||
val height = imageWidth * (1f / aspectRationToUse)
|
val height = imageWidth * (1f / aspectRationToUse)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(spaceBetween),
|
verticalArrangement = Arrangement.spacedBy(4.dp), // Fixed base spacing
|
||||||
modifier = modifier.size(width, height),
|
modifier = modifier.size(width, height),
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
|
|
@ -173,8 +179,16 @@ fun SeasonCard(
|
||||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.padding(bottom = spaceBelow)
|
// Optimization: move animation reads to layout/draw phase
|
||||||
.fillMaxWidth(),
|
.offset {
|
||||||
|
IntOffset(0, (spaceBetween.value - 4.dp).roundToPx())
|
||||||
|
}.layout { measurable, constraints ->
|
||||||
|
val paddingPx = spaceBelow.value.roundToPx()
|
||||||
|
val placeable = measurable.measure(constraints)
|
||||||
|
layout(placeable.width, placeable.height + paddingPx) {
|
||||||
|
placeable.placeRelative(0, 0)
|
||||||
|
}
|
||||||
|
}.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = title ?: "",
|
text = title ?: "",
|
||||||
|
|
|
||||||
|
|
@ -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.Surface
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a re-implementation of [androidx.tv.material3.Button] with altered sizing, padding, colors, etc
|
||||||
|
*
|
||||||
|
* This allows for creating smaller and fully circular Buttons.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun Button(
|
fun Button(
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.NonRestartableComposable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
|
@ -63,6 +64,7 @@ import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||||
import com.github.damontecres.wholphin.services.MediaManagementService
|
import com.github.damontecres.wholphin.services.MediaManagementService
|
||||||
import com.github.damontecres.wholphin.services.MediaReportService
|
import com.github.damontecres.wholphin.services.MediaReportService
|
||||||
|
import com.github.damontecres.wholphin.services.MusicService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
||||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||||
|
|
@ -79,6 +81,7 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.music.addToQueue
|
||||||
import com.github.damontecres.wholphin.ui.equalsNotNull
|
import com.github.damontecres.wholphin.ui.equalsNotNull
|
||||||
import com.github.damontecres.wholphin.ui.launchDefault
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
|
|
@ -138,6 +141,7 @@ class CollectionFolderViewModel
|
||||||
private val themeSongPlayer: ThemeSongPlayer,
|
private val themeSongPlayer: ThemeSongPlayer,
|
||||||
private val userPreferencesService: UserPreferencesService,
|
private val userPreferencesService: UserPreferencesService,
|
||||||
private val mediaManagementService: MediaManagementService,
|
private val mediaManagementService: MediaManagementService,
|
||||||
|
private val musicService: MusicService,
|
||||||
val mediaReportService: MediaReportService,
|
val mediaReportService: MediaReportService,
|
||||||
@Assisted itemId: String,
|
@Assisted itemId: String,
|
||||||
@Assisted initialSortAndDirection: SortAndDirection?,
|
@Assisted initialSortAndDirection: SortAndDirection?,
|
||||||
|
|
@ -398,6 +402,7 @@ class CollectionFolderViewModel
|
||||||
ImageType.PRIMARY,
|
ImageType.PRIMARY,
|
||||||
ImageType.THUMB,
|
ImageType.THUMB,
|
||||||
ImageType.BACKDROP,
|
ImageType.BACKDROP,
|
||||||
|
ImageType.LOGO,
|
||||||
),
|
),
|
||||||
includeItemTypes = includeItemTypes,
|
includeItemTypes = includeItemTypes,
|
||||||
recursive = recursive,
|
recursive = recursive,
|
||||||
|
|
@ -530,6 +535,11 @@ class CollectionFolderViewModel
|
||||||
item: BaseItem,
|
item: BaseItem,
|
||||||
appPreferences: AppPreferences,
|
appPreferences: AppPreferences,
|
||||||
): Boolean = mediaManagementService.canDelete(item, appPreferences)
|
): Boolean = mediaManagementService.canDelete(item, appPreferences)
|
||||||
|
|
||||||
|
fun addToQueue(
|
||||||
|
item: BaseItem,
|
||||||
|
index: Int,
|
||||||
|
) = addToQueue(api, musicService, item, index)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -774,6 +784,9 @@ fun CollectionFolderGrid(
|
||||||
onClickGoTo = {
|
onClickGoTo = {
|
||||||
onClickItem.invoke(position, it)
|
onClickItem.invoke(position, it)
|
||||||
},
|
},
|
||||||
|
onClickAddToQueue = {
|
||||||
|
viewModel.addToQueue(it, 0)
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onDismissRequest = { moreDialog.makeAbsent() },
|
onDismissRequest = { moreDialog.makeAbsent() },
|
||||||
|
|
@ -881,15 +894,7 @@ fun CollectionFolderGridContent(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
if (showTitle) {
|
if (showTitle) {
|
||||||
Text(
|
GridTitle(title)
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.displayMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
val endPadding =
|
val endPadding =
|
||||||
16.dp + if (sortAndDirection.sort == ItemSortBy.SORT_NAME) 24.dp else 0.dp
|
16.dp + if (sortAndDirection.sort == ItemSortBy.SORT_NAME) 24.dp else 0.dp
|
||||||
|
|
@ -960,11 +965,12 @@ fun CollectionFolderGridContent(
|
||||||
AnimatedVisibility(viewOptions.showDetails) {
|
AnimatedVisibility(viewOptions.showDetails) {
|
||||||
HomePageHeader(
|
HomePageHeader(
|
||||||
item = focusedItem,
|
item = focusedItem,
|
||||||
|
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(200.dp)
|
.height(200.dp)
|
||||||
.padding(top = 48.dp, bottom = 32.dp, start = 8.dp),
|
.padding(HeaderUtils.padding),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
when (val state = loadingState) {
|
when (val state = loadingState) {
|
||||||
|
|
@ -1144,3 +1150,18 @@ val CollectionType.baseItemKinds: List<BaseItemKind>
|
||||||
listOf()
|
listOf()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@NonRestartableComposable
|
||||||
|
fun GridTitle(
|
||||||
|
title: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) = Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.displayMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,8 @@ import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||||
import org.jellyfin.sdk.model.api.MediaStream
|
import org.jellyfin.sdk.model.api.MediaStream
|
||||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parameters for rendering a [DialogPopup]
|
* Parameters for rendering a [DialogPopup]
|
||||||
|
|
@ -90,6 +92,9 @@ sealed interface DialogItemEntry
|
||||||
|
|
||||||
data object DialogItemDivider : DialogItemEntry
|
data object DialogItemDivider : DialogItemEntry
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An item within a [DialogPopup]
|
||||||
|
*/
|
||||||
data class DialogItem(
|
data class DialogItem(
|
||||||
val headlineContent: @Composable () -> Unit,
|
val headlineContent: @Composable () -> Unit,
|
||||||
val onClick: () -> Unit,
|
val onClick: () -> Unit,
|
||||||
|
|
@ -594,6 +599,7 @@ fun ConfirmDeleteDialog(
|
||||||
fun chooseVersionParams(
|
fun chooseVersionParams(
|
||||||
context: Context,
|
context: Context,
|
||||||
sources: List<MediaSourceInfo>,
|
sources: List<MediaSourceInfo>,
|
||||||
|
chosenSourceId: UUID?,
|
||||||
onClick: (Int) -> Unit,
|
onClick: (Int) -> Unit,
|
||||||
): DialogParams =
|
): DialogParams =
|
||||||
DialogParams(
|
DialogParams(
|
||||||
|
|
@ -601,6 +607,7 @@ fun chooseVersionParams(
|
||||||
title = context.getString(R.string.choose_stream, context.getString(R.string.version)),
|
title = context.getString(R.string.choose_stream, context.getString(R.string.version)),
|
||||||
items =
|
items =
|
||||||
sources.filter { it.id.isNotNullOrBlank() }.mapIndexed { index, source ->
|
sources.filter { it.id.isNotNullOrBlank() }.mapIndexed { index, source ->
|
||||||
|
val uuid = source.id?.toUUIDOrNull()
|
||||||
val videoStream =
|
val videoStream =
|
||||||
source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }
|
source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }
|
||||||
val title = source.name ?: source.path ?: source.id ?: ""
|
val title = source.name ?: source.path ?: source.id ?: ""
|
||||||
|
|
@ -608,6 +615,9 @@ fun chooseVersionParams(
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
Text(text = title)
|
Text(text = title)
|
||||||
},
|
},
|
||||||
|
leadingContent = {
|
||||||
|
SelectedLeadingContent(uuid != null && uuid == chosenSourceId)
|
||||||
|
},
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
videoStream?.displayTitle?.let { Text(text = it) }
|
videoStream?.displayTitle?.let { Text(text = it) }
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
|
@ -49,8 +48,10 @@ import androidx.tv.material3.MaterialTheme
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
import com.google.protobuf.value
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An input field for text customized for TV entry
|
||||||
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun EditTextBox(
|
fun EditTextBox(
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs
|
import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs
|
||||||
|
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
|
@ -98,3 +99,13 @@ fun ErrorMessage(
|
||||||
exception = error.exception,
|
exception = error.exception,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ErrorMessage(
|
||||||
|
error: DataLoadingState.Error,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) = ErrorMessage(
|
||||||
|
message = error.message,
|
||||||
|
exception = error.exception,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ import com.github.damontecres.wholphin.data.filter.GenreFilter
|
||||||
import com.github.damontecres.wholphin.data.filter.ItemFilterBy
|
import com.github.damontecres.wholphin.data.filter.ItemFilterBy
|
||||||
import com.github.damontecres.wholphin.data.filter.OfficialRatingFilter
|
import com.github.damontecres.wholphin.data.filter.OfficialRatingFilter
|
||||||
import com.github.damontecres.wholphin.data.filter.PlayedFilter
|
import com.github.damontecres.wholphin.data.filter.PlayedFilter
|
||||||
|
import com.github.damontecres.wholphin.data.filter.StudioFilter
|
||||||
import com.github.damontecres.wholphin.data.filter.VideoTypeFilter
|
import com.github.damontecres.wholphin.data.filter.VideoTypeFilter
|
||||||
import com.github.damontecres.wholphin.data.filter.YearFilter
|
import com.github.damontecres.wholphin.data.filter.YearFilter
|
||||||
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||||
|
|
@ -55,6 +56,14 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Button for filtering data.
|
||||||
|
*
|
||||||
|
* Clicking on it will show a drop-down menu of filterable options. Many of these show a second drop-down menu when clicked.
|
||||||
|
*
|
||||||
|
* @see GetItemsFilter
|
||||||
|
* @see ItemFilterBy
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun FilterByButton(
|
fun FilterByButton(
|
||||||
filterOptions: List<ItemFilterBy<*>>,
|
filterOptions: List<ItemFilterBy<*>>,
|
||||||
|
|
@ -198,7 +207,9 @@ fun FilterByButton(
|
||||||
val isSelected =
|
val isSelected =
|
||||||
remember(currentValue) {
|
remember(currentValue) {
|
||||||
when (filterOption) {
|
when (filterOption) {
|
||||||
GenreFilter -> {
|
GenreFilter,
|
||||||
|
StudioFilter,
|
||||||
|
-> {
|
||||||
(currentValue as? List<UUID>)
|
(currentValue as? List<UUID>)
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
.contains(value.value)
|
.contains(value.value)
|
||||||
|
|
@ -263,7 +274,9 @@ fun FilterByButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
val newFilter =
|
val newFilter =
|
||||||
when (filterOption) {
|
when (filterOption) {
|
||||||
GenreFilter -> {
|
GenreFilter,
|
||||||
|
StudioFilter,
|
||||||
|
-> {
|
||||||
val list = (currentValue as? List<UUID>).orEmpty()
|
val list = (currentValue as? List<UUID>).orEmpty()
|
||||||
val newValue =
|
val newValue =
|
||||||
list
|
list
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,7 @@ class GenreViewModel
|
||||||
nameLessThan = letter.toString(),
|
nameLessThan = letter.toString(),
|
||||||
limit = 0,
|
limit = 0,
|
||||||
enableTotalRecordCount = true,
|
enableTotalRecordCount = true,
|
||||||
|
includeItemTypes = includeItemTypes,
|
||||||
)
|
)
|
||||||
val result by GetGenresRequestHandler.execute(api, request)
|
val result by GetGenresRequestHandler.execute(api, request)
|
||||||
return@withContext result.totalRecordCount
|
return@withContext result.totalRecordCount
|
||||||
|
|
@ -156,6 +157,9 @@ private val genreCache by lazy {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mapping from genre IDs to image URLs using random items within each genre
|
||||||
|
*/
|
||||||
suspend fun getGenreImageMap(
|
suspend fun getGenreImageMap(
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
userId: UUID?,
|
userId: UUID?,
|
||||||
|
|
@ -230,6 +234,9 @@ data class Genre(
|
||||||
override val sortName: String get() = name
|
override val sortName: String get() = name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show an optimized grid of genres for a library
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun GenreCardGrid(
|
fun GenreCardGrid(
|
||||||
itemId: UUID,
|
itemId: UUID,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,11 @@ import androidx.tv.material3.Text
|
||||||
|
|
||||||
private const val MAX_TO_SHOW = 4
|
private const val MAX_TO_SHOW = 4
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display a comma separated list of genres
|
||||||
|
*
|
||||||
|
* Only the first few will be shown
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun GenreText(
|
fun GenreText(
|
||||||
genres: List<String>,
|
genres: List<String>,
|
||||||
|
|
|
||||||
|
|
@ -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
|
@Composable
|
||||||
fun ItemGrid(
|
fun ItemGrid(
|
||||||
destination: Destination.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.android.produceLibraries
|
||||||
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
|
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays dependencies' license information to comply with attribution
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun LicenseInfo(modifier: Modifier = Modifier) {
|
fun LicenseInfo(modifier: Modifier = Modifier) {
|
||||||
val libraries by produceLibraries()
|
val libraries by produceLibraries()
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,9 @@ import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.ui.playOnClickSound
|
import com.github.damontecres.wholphin.ui.playOnClickSound
|
||||||
import com.github.damontecres.wholphin.ui.playSoundOnFocus
|
import com.github.damontecres.wholphin.ui.playSoundOnFocus
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the overview text for an item. Uses a fixed size and allows for clicking.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun OverviewText(
|
fun OverviewText(
|
||||||
overview: String,
|
overview: String,
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,8 @@ import androidx.tv.material3.Icon
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.ui.TimeFormatter
|
|
||||||
import com.github.damontecres.wholphin.ui.dot
|
import com.github.damontecres.wholphin.ui.dot
|
||||||
|
import com.github.damontecres.wholphin.ui.getTimeFormatter
|
||||||
import com.github.damontecres.wholphin.ui.util.LocalClock
|
import com.github.damontecres.wholphin.ui.util.LocalClock
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
|
@ -107,7 +107,7 @@ fun TimeRemaining(
|
||||||
val now by LocalClock.current.now
|
val now by LocalClock.current.now
|
||||||
val remainingStr =
|
val remainingStr =
|
||||||
remember(remaining, now) {
|
remember(remaining, now) {
|
||||||
val endTimeStr = TimeFormatter.format(now.plusSeconds(remaining.inWholeSeconds))
|
val endTimeStr = getTimeFormatter().format(now.plusSeconds(remaining.inWholeSeconds))
|
||||||
buildAnnotatedString {
|
buildAnnotatedString {
|
||||||
dot()
|
dot()
|
||||||
append(context.getString(R.string.ends_at, endTimeStr))
|
append(context.getString(R.string.ends_at, endTimeStr))
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,14 @@ import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.BackdropService
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||||
import com.github.damontecres.wholphin.services.MediaManagementService
|
import com.github.damontecres.wholphin.services.MediaManagementService
|
||||||
import com.github.damontecres.wholphin.services.MediaReportService
|
import com.github.damontecres.wholphin.services.MediaReportService
|
||||||
|
import com.github.damontecres.wholphin.services.MusicService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.services.deleteItem
|
import com.github.damontecres.wholphin.services.deleteItem
|
||||||
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
||||||
|
|
@ -34,6 +36,7 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.music.addToQueue
|
||||||
import com.github.damontecres.wholphin.ui.launchDefault
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.main.HomePageContent
|
import com.github.damontecres.wholphin.ui.main.HomePageContent
|
||||||
|
|
@ -42,18 +45,25 @@ import com.github.damontecres.wholphin.ui.rememberPosition
|
||||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Deferred
|
import kotlinx.coroutines.Deferred
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.model.api.MediaType
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract [ViewModel] for the "Recommended" tab for a library
|
||||||
|
*/
|
||||||
abstract class RecommendedViewModel(
|
abstract class RecommendedViewModel(
|
||||||
val context: Context,
|
@param:ApplicationContext val context: Context,
|
||||||
|
val api: ApiClient,
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
val favoriteWatchManager: FavoriteWatchManager,
|
val favoriteWatchManager: FavoriteWatchManager,
|
||||||
val mediaReportService: MediaReportService,
|
val mediaReportService: MediaReportService,
|
||||||
|
private val musicService: MusicService,
|
||||||
private val backdropService: BackdropService,
|
private val backdropService: BackdropService,
|
||||||
private val mediaManagementService: MediaManagementService,
|
private val mediaManagementService: MediaManagementService,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
@ -110,13 +120,14 @@ abstract class RecommendedViewModel(
|
||||||
|
|
||||||
fun update(
|
fun update(
|
||||||
@StringRes title: Int,
|
@StringRes title: Int,
|
||||||
|
viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
|
||||||
block: suspend () -> List<BaseItem>,
|
block: suspend () -> List<BaseItem>,
|
||||||
): Deferred<HomeRowLoadingState> =
|
): Deferred<HomeRowLoadingState> =
|
||||||
viewModelScope.async(Dispatchers.IO) {
|
viewModelScope.async(Dispatchers.IO) {
|
||||||
val titleStr = context.getString(title)
|
val titleStr = context.getString(title)
|
||||||
val row =
|
val row =
|
||||||
try {
|
try {
|
||||||
HomeRowLoadingState.Success(titleStr, block.invoke())
|
HomeRowLoadingState.Success(titleStr, block.invoke(), viewOptions)
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
HomeRowLoadingState.Error(titleStr, null, ex)
|
HomeRowLoadingState.Error(titleStr, null, ex)
|
||||||
}
|
}
|
||||||
|
|
@ -141,6 +152,11 @@ abstract class RecommendedViewModel(
|
||||||
item: BaseItem,
|
item: BaseItem,
|
||||||
appPreferences: AppPreferences,
|
appPreferences: AppPreferences,
|
||||||
): Boolean = mediaManagementService.canDelete(item, appPreferences)
|
): Boolean = mediaManagementService.canDelete(item, appPreferences)
|
||||||
|
|
||||||
|
fun addToQueue(
|
||||||
|
item: BaseItem,
|
||||||
|
index: Int,
|
||||||
|
) = addToQueue(api, musicService, item, index)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -205,6 +221,7 @@ fun RecommendedContent(
|
||||||
},
|
},
|
||||||
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
||||||
onUpdateBackdrop = viewModel::updateBackdrop,
|
onUpdateBackdrop = viewModel::updateBackdrop,
|
||||||
|
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -237,6 +254,9 @@ fun RecommendedContent(
|
||||||
},
|
},
|
||||||
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
||||||
onClickDelete = { showDeleteDialog = RowColumnItem(position, item) },
|
onClickDelete = { showDeleteDialog = RowColumnItem(position, item) },
|
||||||
|
onClickAddToQueue = {
|
||||||
|
viewModel.addToQueue(it, 0)
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onDismissRequest = { moreDialog.makeAbsent() },
|
onDismissRequest = { moreDialog.makeAbsent() },
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||||
import com.github.damontecres.wholphin.services.MediaManagementService
|
import com.github.damontecres.wholphin.services.MediaManagementService
|
||||||
import com.github.damontecres.wholphin.services.MediaReportService
|
import com.github.damontecres.wholphin.services.MediaReportService
|
||||||
|
import com.github.damontecres.wholphin.services.MusicService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.services.SuggestionService
|
import com.github.damontecres.wholphin.services.SuggestionService
|
||||||
import com.github.damontecres.wholphin.services.SuggestionsResource
|
import com.github.damontecres.wholphin.services.SuggestionsResource
|
||||||
|
|
@ -55,7 +56,8 @@ class RecommendedMovieViewModel
|
||||||
@AssistedInject
|
@AssistedInject
|
||||||
constructor(
|
constructor(
|
||||||
@ApplicationContext context: Context,
|
@ApplicationContext context: Context,
|
||||||
private val api: ApiClient,
|
api: ApiClient,
|
||||||
|
musicService: MusicService,
|
||||||
private val serverRepository: ServerRepository,
|
private val serverRepository: ServerRepository,
|
||||||
private val preferencesDataStore: DataStore<AppPreferences>,
|
private val preferencesDataStore: DataStore<AppPreferences>,
|
||||||
private val suggestionService: SuggestionService,
|
private val suggestionService: SuggestionService,
|
||||||
|
|
@ -67,9 +69,11 @@ class RecommendedMovieViewModel
|
||||||
mediaManagementService: MediaManagementService,
|
mediaManagementService: MediaManagementService,
|
||||||
) : RecommendedViewModel(
|
) : RecommendedViewModel(
|
||||||
context,
|
context,
|
||||||
|
api,
|
||||||
navigationManager,
|
navigationManager,
|
||||||
favoriteWatchManager,
|
favoriteWatchManager,
|
||||||
mediaReportService,
|
mediaReportService,
|
||||||
|
musicService,
|
||||||
backdropService,
|
backdropService,
|
||||||
mediaManagementService,
|
mediaManagementService,
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -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