diff --git a/.github/actions/native-build/action.yml b/.github/actions/native-build/action.yml
deleted file mode 100644
index 770e552a..00000000
--- a/.github/actions/native-build/action.yml
+++ /dev/null
@@ -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 }}
diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml
index 774dae38..d7b2f290 100644
--- a/.github/actions/setup/action.yml
+++ b/.github/actions/setup/action.yml
@@ -21,7 +21,7 @@ runs:
echo "BUILD_TOOLS_VERSION=36.0.0" >> $GITHUB_ENV
echo "NDK_VERSION=29.0.14206865" >> $GITHUB_ENV
- name: Setup Android SDK
- uses: android-actions/setup-android@v3
+ uses: android-actions/setup-android@v4
with:
packages: "tools platform-tools build-tools;${{ env.BUILD_TOOLS_VERSION }} ndk;${{ env.NDK_VERSION }}"
- name: Add NDK to path
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 9583525d..b50a58b8 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -30,10 +30,6 @@ jobs:
fetch-depth: 0 # Need the tags to build
- name: Setup
uses: ./.github/actions/setup
- - name: Native build
- uses: ./.github/actions/native-build
- with:
- cache: true
- name: Get version names
run: |
@@ -55,8 +51,11 @@ jobs:
KEY_PASSWORD: "${{ secrets.KEY_PASSWORD }}"
KEY_STORE_PASSWORD: "${{ secrets.KEY_STORE_PASSWORD }}"
SIGNING_KEY: "${{ secrets.SIGNING_KEY }}"
+ ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}"
+ ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}"
run: |
- ./gradlew clean assembleRelease assembleDebug --no-daemon
+ ./gradlew clean assembleDefaultRelease assembleDefaultDebug --no-daemon
+
- name: Verify signatures
run: |
echo "Verify APK signatures"
@@ -68,12 +67,10 @@ jobs:
find app/build/outputs/apk -name '*.apk' -print0 |
while IFS= read -r -d '' line; do
# Wholphin-release-0.2.9-62-g5c12e03-16-arm64-v8a.apk => Wholphin-arm64-v8a.apk
- short_name="$(echo $line | sed -E 's/-[0-9]+\.[0-9]+\.[0-9]+-[0-9]+-g[a-fA-F0-9]+-[0-9]+//')"
+ short_name="$(echo $line | sed -E 's/-[0-9]+\.[0-9]+\.[0-9]+-[0-9]+-g[a-fA-F0-9]+-[0-9]+//' | sed -E 's/-default//')"
echo "$line => $short_name"
cp "$line" "$short_name"
done
- apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//')
- echo "apks=$apks" >> "$GITHUB_OUTPUT"
- name: Checksums
run: |
echo "SHA256 checksums:"
@@ -82,6 +79,8 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
+ shopt -s globstar
+
gh release delete "${{ env.TAG_NAME }}" --cleanup-tag -y || true
@@ -98,4 +97,4 @@ jobs:
gh release create "${{ env.TAG_NAME }}" \
--latest=false --prerelease --title "${{ env.VERSION_NAME }}" --target "${{ env.BRANCH_NAME }}" -F ci_release_note.txt \
- "app/build/outputs/apk/**/*.apk"
+ app/build/outputs/apk/**/*.apk
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 0b0f2204..bde4e1ef 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -36,26 +36,9 @@ jobs:
fetch-depth: 0 # Need the tags to build
- name: Setup
uses: ./.github/actions/setup
- - name: Native build
- uses: ./.github/actions/native-build
- with:
- cache: true
- name: Build app
id: buildapp
run: |
- ./gradlew clean assembleDebug testDebugUnitTest --no-daemon
+ ./gradlew clean assembleDefaultDebug testDefaultDebugUnitTest --no-daemon
apks=$(find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | tr '\0' ',' | sed 's/,$//')
echo "apks=$apks" >> "$GITHUB_OUTPUT"
-
- test-patch:
- runs-on: ubuntu-latest
- needs: pre-commit
- steps:
- - name: Checkout the code
- uses: actions/checkout@v6
- with:
- fetch-depth: 1
- - name: Test applying patch
- run: |
- git apply app/src/patches/play_store.patch
- git diff
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 9611c810..fb9d21f1 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -26,10 +26,6 @@ jobs:
fetch-depth: 0 # Need the tags to build
- name: Setup
uses: ./.github/actions/setup
- - name: Native build
- uses: ./.github/actions/native-build
- with:
- cache: false
- name: Build app
id: buildapp
env:
@@ -37,21 +33,11 @@ jobs:
KEY_PASSWORD: "${{ secrets.KEY_PASSWORD }}"
KEY_STORE_PASSWORD: "${{ secrets.KEY_STORE_PASSWORD }}"
SIGNING_KEY: "${{ secrets.SIGNING_KEY }}"
+ ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}"
+ ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}"
run: |
- ./gradlew clean assembleRelease --no-daemon
+ ./gradlew clean assembleDefaultRelease bundleAppstoreRelease bundleFiretvRelease --no-daemon
- - name: Build app
- id: buildaab
- env:
- KEY_ALIAS: "${{ secrets.KEY_ALIAS }}"
- KEY_PASSWORD: "${{ secrets.KEY_PASSWORD }}"
- KEY_STORE_PASSWORD: "${{ secrets.KEY_STORE_PASSWORD }}"
- SIGNING_KEY: "${{ secrets.SIGNING_KEY }}"
- run: |
- git apply app/src/patches/play_store.patch
- ./gradlew bundleRelease --no-daemon
- aab=$(find app/build/outputs -name '*.aab')
- echo "aab=$aab" >> "$GITHUB_OUTPUT"
- name: Verify signatures
run: |
echo "Verify APK signatures"
@@ -62,13 +48,11 @@ jobs:
run: |
find app/build/outputs/apk -name '*.apk' -print0 |
while IFS= read -r -d '' line; do
- # Wholphin-release-0.2.9-62-g5c12e03-16-arm64-v8a.apk => Wholphin-arm64-v8a.apk
- short_name="$(echo $line | sed -E 's/-release-[0-9]+\.[0-9]+\.[0-9]+-[0-9]+-g[a-fA-F0-9]+-[0-9]+//')"
+ # Wholphin-default-release-0.2.9-62-g5c12e03-16-arm64-v8a.apk => Wholphin-arm64-v8a.apk
+ short_name="$(echo $line | sed -E 's/-default-release-[0-9]+\.[0-9]+\.[0-9]+-[0-9]+-g[a-fA-F0-9]+-[0-9]+//')"
echo "$line => $short_name"
cp "$line" "$short_name"
done
- apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//')
- echo "apks=$apks" >> "$GITHUB_OUTPUT"
- name: Checksums
run: |
echo "SHA256 checksums:"
@@ -84,6 +68,8 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
+ shopt -s globstar
+
gh release create "${{ env.TAG_NAME }}" \
--latest --title "${{ env.TAG_NAME }}" --verify-tag -n "" --draft \
- "app/build/outputs/apk/**/*.apk"
+ app/build/outputs/apk/**/*.apk
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index 6856ebb2..2026c9ee 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -14,6 +14,7 @@ The app uses:
* [Room](https://developer.android.com/training/data-storage/room) & [DataStore](https://developer.android.com/topic/libraries/architecture/datastore) for local data storage
* [Hilt](https://developer.android.com/training/dependency-injection/hilt-android) for dependency injection
* [Media3/ExoPlayer](https://developer.android.com/media/media3/exoplayer) for media playback
+* [MPV/libmpv](https://github.com/mpv-player/mpv) for media playback
* [Coil](https://coil-kt.github.io/coil/) for image loading
* [OkHttp](https://square.github.io/okhttp/) for HTTP requests
@@ -42,21 +43,13 @@ Code is split into several packages:
- `ui` - User interface code and ViewModels
- `util` - Utility classes and functions
-### Native components
+## Extensions
-#### FFmpeg decoder module
+Wholphin uses several native components for extra playback compatibility. This includes Media3 ffmpeg/av1 decoders and `libmpv`. These extensions are not required to build the app, but without them some functionality will not work.
-Wholphin ships with [media3 ffmpeg decoder module](https://github.com/androidx/media/blob/release/libraries/decoder_ffmpeg/README.md).
+If you want to include these in a local build, see the [instructions here](https://github.com/damontecres/wholphin-extensions?tab=readme-ov-file#usage) for configuring the repository.
-It is not required to build the extension in order to build the app locally.
-
-You can build the module on MacOS or Linux with the [`build_ffmpeg_decoder.sh`](./scripts/ffmpeg/build_ffmpeg_decoder.sh) script.
-
-#### MPV player backend
-
-Wholphin has a playback engine that uses [`libmpv`](https://github.com/mpv-player/mpv). The app uses JNI code from [`mpv-android`](https://github.com/mpv-android/mpv-android) and has an implementation of `androidx.media3.common.Player` to swap out for `ExoPlayer`.
-
-See the [build scripts](scripts/mpv/) for details on building this component.
+You can also build the extensions locally from https://github.com/damontecres/wholphin-extensions and include them in `app/libs`. The gradle build dependency resolution prefers these local files over fetching from the remote maven registry.
### App settings
diff --git a/README.md b/README.md
index 6a3c4c65..0957c4ed 100644
--- a/README.md
+++ b/README.md
@@ -33,7 +33,8 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin
- Customize the home page to see the content you are interested in
- Use poster or thumb images, show/hide titles, add/remove/re-order different types of rows!
- A navigation drawer for quick access to libraries, favorites, search, and settings from almost anywhere in the app
-- Integration with [Jellyseerr](https://github.com/seerr-team/seerr) to discover new movies and TV shows
+- Integration with [Jellyseerr/Seerr](https://github.com/seerr-team/seerr) to discover new movies and TV shows
+ - Note: only available when installed from [GitHub](https://github.com/damontecres/Wholphin/releases/latest) or the [Play store](https://play.google.com/store/apps/details?id=com.github.damontecres.wholphin)
- Option to combine Continue Watching & Next Up rows
- Show Movie/TV Show titles when browsing libraries
- Play theme music, if available
@@ -58,7 +59,6 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin
- Trickplay support
- Subtly show playback position along the bottom of the screen while seeking w/ D-Pad
-
### Roadmap
See [here for the roadmap](https://github.com/damontecres/Wholphin/wiki#roadmap)
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 22bd0deb..56c80b90 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,3 +1,4 @@
+import com.android.build.api.dsl.ProductFlavor
import com.google.protobuf.gradle.id
import com.mikepenz.aboutlibraries.plugin.DuplicateMode
import com.mikepenz.aboutlibraries.plugin.DuplicateRule
@@ -21,6 +22,8 @@ val isCI = if (System.getenv("CI") != null) System.getenv("CI").toBoolean() else
val shouldSign = isCI && System.getenv("KEY_ALIAS") != null
val ffmpegModuleExists = project.file("libs/lib-decoder-ffmpeg-release.aar").exists()
val av1ModuleExists = project.file("libs/lib-decoder-av1-release.aar").exists()
+val mpvModuleExists = project.file("libs/wholphin-mpv-release.aar").exists()
+val extensionsRepoActive = project.hasProperty("WholphinExtensionsUsername")
val gitTags =
providers
@@ -45,6 +48,8 @@ android {
versionCode = gitTags.trim().lines().size
versionName = gitDescribe.trim().removePrefix("v").ifBlank { "0.0.0" }
testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner"
+
+ buildConfigField("long", "BUILD_TIME", System.currentTimeMillis().toString())
}
signingConfigs {
@@ -106,6 +111,38 @@ android {
}
}
}
+ flavorDimensions += "version"
+ productFlavors {
+ val featureLeanback = "leanback"
+ val featureUpdate = "UPDATING_ENABLED"
+ val featureDiscover = "DISCOVER_ENABLED"
+
+ fun ProductFlavor.setFeatureFlag(
+ name: String,
+ enabled: Boolean,
+ ) {
+ this.buildConfigField("boolean", name, "Boolean.parseBoolean(\"${enabled}\")")
+ }
+ create("default") {
+ dimension = "version"
+ isDefault = true
+ manifestPlaceholders += mapOf(featureLeanback to false)
+ setFeatureFlag(featureUpdate, true)
+ setFeatureFlag(featureDiscover, true)
+ }
+ create("appstore") {
+ dimension = "version"
+ manifestPlaceholders += mapOf(featureLeanback to true)
+ setFeatureFlag(featureUpdate, false)
+ setFeatureFlag(featureDiscover, true)
+ }
+ create("firetv") {
+ dimension = "version"
+ manifestPlaceholders += mapOf(featureLeanback to true)
+ setFeatureFlag(featureUpdate, false)
+ setFeatureFlag(featureDiscover, false)
+ }
+ }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
@@ -134,6 +171,12 @@ android {
isUniversalApk = true
}
}
+ packaging {
+ jniLibs {
+ // Work around because libass-android & wholphin-mpv both (incorrectly) package libc++_shared.so
+ pickFirsts += "lib/*/libc++_shared.so"
+ }
+ }
sourceSets {
getByName("main") {
@@ -146,6 +189,10 @@ android {
isIncludeAndroidResources = true
}
}
+
+ lint {
+ disable.add("MissingTranslation")
+ }
}
protobuf {
@@ -219,6 +266,7 @@ dependencies {
implementation(libs.androidx.lifecycle.livedata.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.datastore)
+ implementation(libs.androidx.datastore.preferences)
implementation(libs.protobuf.kotlin.lite)
implementation(libs.androidx.tvprovider)
implementation(libs.androidx.work.runtime.ktx)
@@ -231,6 +279,7 @@ dependencies {
implementation(libs.androidx.media3.exoplayer.dash)
implementation(libs.androidx.media3.ui)
implementation(libs.androidx.media3.ui.compose)
+ implementation(libs.ass.media)
implementation(libs.coil.core)
implementation(libs.coil.compose)
@@ -287,11 +336,33 @@ dependencies {
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
coreLibraryDesugaring(libs.desugar.jdk.libs)
- if (ffmpegModuleExists || isCI) {
+
+ if (ffmpegModuleExists) {
+ logger.info("Using local ffmpeg decoder")
implementation(files("libs/lib-decoder-ffmpeg-release.aar"))
+ } else if (extensionsRepoActive) {
+ logger.info("Using prebuilt ffmpeg decoder")
+ implementation(libs.wholphin.extensions.ffmpeg)
+ } else {
+ logger.warn("Media3 ffmpeg decoder was NOT found")
}
- if (av1ModuleExists || isCI) {
+ if (av1ModuleExists) {
+ logger.info("Using local av1 decoder")
implementation(files("libs/lib-decoder-av1-release.aar"))
+ } else if (extensionsRepoActive) {
+ logger.info("Using prebuilt av1 decoder")
+ implementation(libs.wholphin.extensions.av1)
+ } else {
+ logger.warn("Media3 av1 decoder was NOT found")
+ }
+ if (mpvModuleExists) {
+ logger.info("Using local libMPV build")
+ implementation(files("libs/wholphin-mpv-release.aar"))
+ } else if (extensionsRepoActive) {
+ logger.info("Using prebuilt libMPV")
+ implementation(libs.wholphin.extensions.mpv)
+ } else {
+ logger.warn("libMPV was NOT found")
}
testImplementation(libs.mockk.android)
diff --git a/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt b/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt
index 72cf2896..09dc6745 100644
--- a/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt
+++ b/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt
@@ -9,7 +9,9 @@ import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
+import androidx.navigation3.runtime.NavBackStack
import com.github.damontecres.wholphin.MainContent
+import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.ScreensaverState
import com.github.damontecres.wholphin.services.SetupDestination
@@ -43,16 +45,17 @@ class InstrumentedBasicUiTests {
@OptIn(ExperimentalTestApi::class)
@Test
fun myTest() {
+ val navigationManager = NavigationManager()
+ navigationManager.backStack = NavBackStack(Destination.Home())
// Start the app
composeTestRule.setContent {
WholphinTheme {
MainContent(
backStack = mutableListOf(SetupDestination.ServerList),
- navigationManager = mockk(relaxed = true),
+ navigationManager = navigationManager,
appPreferences = mockk(relaxed = true),
backdropService = mockk(relaxed = true),
screensaverService = screensaverService,
- requestedDestination = Destination.Home(),
modifier = Modifier,
)
}
diff --git a/app/src/appstore/AndroidManifest.xml b/app/src/appstore/AndroidManifest.xml
new file mode 100644
index 00000000..8a762a28
--- /dev/null
+++ b/app/src/appstore/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml
deleted file mode 100644
index 29b2eb75..00000000
--- a/app/src/debug/AndroidManifest.xml
+++ /dev/null
@@ -1,94 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 4479ae86..e75ab500 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -20,7 +20,7 @@
android:required="false" />
+ android:required="${leanback}" />
@@ -30,6 +30,10 @@
+
+
+
+
>(backStackStr)
+ if (!savedInstanceState.getBoolean(KEY_EXTERNAL_PLAYER)) {
+ val lastDest = backStack.lastOrNull()
+ if (lastDest is Destination.Playback ||
+ lastDest is Destination.PlaybackList ||
+ lastDest is Destination.Slideshow
+ ) {
+ Timber.v("Restoring back stack with playback")
+ backStack = backStack.toMutableList().apply { removeAt(lastIndex) }
+ }
+ }
+ navigationManager.backStack = NavBackStack(*backStack.toTypedArray())
+ } else {
+ val startDestination = intent?.let(::extractDestination) ?: Destination.Home()
+ navigationManager.backStack = NavBackStack(startDestination)
+ }
+
viewModel.serverRepository.currentUser.observe(this) { user ->
if (user?.hasPin == true) {
window?.setFlags(
@@ -206,17 +241,12 @@ class MainActivity : AppCompatActivity() {
appThemeColors = appPreferences.interfacePreferences.appThemeColors,
) {
ProvideLocalClock {
- val requestedDestination =
- remember(intent) {
- intent?.let(::extractDestination) ?: Destination.Home()
- }
MainContent(
backStack = setupNavigationManager.backStack,
navigationManager = navigationManager,
appPreferences = appPreferences,
backdropService = backdropService,
screensaverService = screensaverService,
- requestedDestination = requestedDestination,
modifier = Modifier.fillMaxSize(),
)
}
@@ -287,6 +317,11 @@ class MainActivity : AppCompatActivity() {
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
Timber.d("onSaveInstanceState")
+ val str = json.encodeToString(navigationManager.backStack.toList())
+ outState.putString(KEY_BACK_STACK, str)
+ val playerBackend =
+ runBlocking { userPreferencesDataStore.data.firstOrNull() }?.playbackPreferences?.playerBackend
+ outState.putBoolean(KEY_EXTERNAL_PLAYER, playerBackend == PlayerBackend.EXTERNAL_PLAYER)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
@@ -362,6 +397,9 @@ class MainActivity : AppCompatActivity() {
const val INTENT_SEASON_NUMBER = "seaNum"
const val INTENT_SEASON_ID = "seaId"
+ private const val KEY_BACK_STACK = "backStack"
+ private const val KEY_EXTERNAL_PLAYER = "extPlayer"
+
lateinit var instance: MainActivity
private set
}
@@ -371,6 +409,7 @@ class MainActivity : AppCompatActivity() {
class MainActivityViewModel
@Inject
constructor(
+ @param:ApplicationContext private val context: Context,
private val preferences: DataStore,
val serverRepository: ServerRepository,
private val navigationManager: SetupNavigationManager,
@@ -379,9 +418,19 @@ class MainActivityViewModel
private val appUpgradeHandler: AppUpgradeHandler,
) : ViewModel() {
fun appStart() {
- viewModelScope.launchIO {
+ viewModelScope.launchDefault {
try {
- appUpgradeHandler.run()
+ val needUpgrade = appUpgradeHandler.needUpgrade()
+ if (needUpgrade) {
+ showToast(
+ context,
+ context.getString(
+ R.string.updated_toast,
+ appUpgradeHandler.currentVersion.toString(),
+ ),
+ )
+ appUpgradeHandler.run()
+ }
appUpgradeHandler.copySubfont(false)
val prefs =
preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
@@ -424,7 +473,7 @@ class MainActivityViewModel
navigationManager.navigateTo(SetupDestination.ServerList)
}
}
- viewModelScope.launchIO {
+ viewModelScope.launchDefault {
// Create the mediaCodecCapabilitiesTest if needed
deviceProfileService.mediaCodecCapabilitiesTest.supportsAVC()
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt
index 5bade867..0794da67 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt
@@ -12,6 +12,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -33,10 +34,8 @@ import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.ui.components.AppScreensaver
import com.github.damontecres.wholphin.ui.nav.ApplicationContent
-import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
-import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
@Composable
fun MainContent(
@@ -45,9 +44,9 @@ fun MainContent(
appPreferences: AppPreferences,
backdropService: BackdropService,
screensaverService: ScreensaverService,
- requestedDestination: Destination,
modifier: Modifier = Modifier,
) {
+ val preferences by rememberUpdatedState(UserPreferences(appPreferences))
Surface(
modifier =
modifier
@@ -96,10 +95,6 @@ fun MainContent(
backdropService.clearBackdrop()
}
val current = key.current
- val preferences =
- remember(appPreferences) {
- UserPreferences(appPreferences)
- }
var showContent by remember {
mutableStateOf(true)
}
@@ -113,7 +108,6 @@ fun MainContent(
ApplicationContent(
user = current.user,
server = current.server,
- startDestination = requestedDestination,
navigationManager = navigationManager,
preferences = preferences,
modifier = Modifier.fillMaxSize(),
diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt
index a4c09ad4..1a350905 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt
@@ -22,9 +22,7 @@ import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
-import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.ScreensaverService
-import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.components.AppScreensaverContent
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
@@ -33,6 +31,7 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
+import timber.log.Timber
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
@@ -61,6 +60,7 @@ class WholphinDreamService :
override fun onCreate() {
super.onCreate()
+ Timber.d("onCreate")
savedStateRegistryController.performRestore(null)
lifecycleRegistry.currentState = Lifecycle.State.CREATED
@@ -74,6 +74,7 @@ class WholphinDreamService :
override fun onAttachedToWindow() {
super.onAttachedToWindow()
+ Timber.d("onAttachedToWindow")
val itemFlow = screensaverService.createItemFlow(lifecycleScope)
setContentView(
ComposeView(this).apply {
@@ -109,11 +110,13 @@ class WholphinDreamService :
override fun onDreamingStarted() {
super.onDreamingStarted()
+ Timber.d("onDreamingStarted")
lifecycleRegistry.currentState = Lifecycle.State.STARTED
}
override fun onDreamingStopped() {
super.onDreamingStopped()
+ Timber.d("onDreamingStopped")
lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt
index 585e1079..ede44e08 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt
@@ -8,12 +8,18 @@ import com.github.damontecres.wholphin.ui.nav.Destination
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.ExtraType
+/**
+ * Represents "extras" for media such as behind-the-scenes or deleted scenes
+ */
sealed interface ExtrasItem {
val parentId: UUID
val type: ExtraType
val destination: Destination
val title: String?
+ /**
+ * Represents multiple extras of the same type
+ */
data class Group(
override val parentId: UUID,
override val type: ExtraType,
@@ -25,6 +31,9 @@ sealed interface ExtrasItem {
override val title: String? = null
}
+ /**
+ * Represents a single extra
+ */
data class Single(
override val parentId: UUID,
override val type: ExtraType,
@@ -38,6 +47,9 @@ sealed interface ExtrasItem {
}
}
+/**
+ * Converts [ExtraType] to the string resource ID
+ */
@get:StringRes
val ExtraType.stringRes: Int
get() =
@@ -56,6 +68,9 @@ val ExtraType.stringRes: Int
ExtraType.SHORT -> R.string.shorts
}
+/**
+ * Converts [ExtraType] to the plural resource ID
+ */
@get:PluralsRes
val ExtraType.pluralRes: Int
get() =
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/LibraryDisplayInfoDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/LibraryDisplayInfoDao.kt
index 0124ac3f..524281cb 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/LibraryDisplayInfoDao.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/LibraryDisplayInfoDao.kt
@@ -7,6 +7,7 @@ import androidx.room.Query
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
import com.github.damontecres.wholphin.ui.toServerString
+import kotlinx.coroutines.flow.Flow
import java.util.UUID
@Dao
@@ -27,6 +28,12 @@ interface LibraryDisplayInfoDao {
itemId: String,
): LibraryDisplayInfo?
+ @Query("SELECT * from LibraryDisplayInfo WHERE userId=:userId AND itemId=:itemId")
+ fun getItemAsFlow(
+ userId: Int,
+ itemId: String,
+ ): Flow
+
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun saveItem(item: LibraryDisplayInfo): Long
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt b/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt
index fec0fb8d..37fd9514 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt
@@ -17,6 +17,19 @@ val DefaultFilterOptions =
DecadeFilter,
)
+val DefaultTvFilterOptions =
+ listOf(
+ PlayedFilter,
+ FavoriteFilter,
+ GenreFilter,
+ StudioFilter,
+ CommunityRatingFilter,
+ OfficialRatingFilter,
+ VideoTypeFilter,
+ YearFilter,
+ DecadeFilter,
+ )
+
val DefaultForFavoritesFilterOptions =
listOf(
PlayedFilter,
@@ -32,6 +45,19 @@ val DefaultForGenresFilterOptions =
listOf(
PlayedFilter,
FavoriteFilter,
+ StudioFilter,
+ CommunityRatingFilter,
+ OfficialRatingFilter,
+ VideoTypeFilter,
+ YearFilter,
+ DecadeFilter,
+ )
+
+val DefaultForStudiosFilterOptions =
+ listOf(
+ PlayedFilter,
+ FavoriteFilter,
+ GenreFilter,
CommunityRatingFilter,
OfficialRatingFilter,
VideoTypeFilter,
@@ -50,6 +76,11 @@ val DefaultPlaylistItemsOptions =
DecadeFilter,
)
+/**
+ * A way to filter libraries
+ *
+ * Gets and sets values within a [GetItemsFilter]
+ */
sealed interface ItemFilterBy {
@get:StringRes
val stringRes: Int
@@ -178,3 +209,16 @@ data object CommunityRatingFilter : ItemFilterBy {
filter: GetItemsFilter,
): GetItemsFilter = filter.copy(minCommunityRating = value?.toDouble())
}
+
+data object StudioFilter : ItemFilterBy> {
+ override val stringRes: Int = R.string.studios
+
+ override val supportMultiple: Boolean = true
+
+ override fun get(filter: GetItemsFilter): List? = filter.studios
+
+ override fun set(
+ value: List?,
+ filter: GetItemsFilter,
+ ): GetItemsFilter = filter.copy(studios = value)
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/AudioItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/AudioItem.kt
new file mode 100644
index 00000000..9c8009db
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/AudioItem.kt
@@ -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,
+ )
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt
index 3c34851b..efb21849 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt
@@ -5,12 +5,13 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
-import com.github.damontecres.wholphin.ui.DateFormatter
import com.github.damontecres.wholphin.ui.abbreviateNumber
import com.github.damontecres.wholphin.ui.detail.CardGridItem
+import com.github.damontecres.wholphin.ui.detail.music.artistsString
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.dot
import com.github.damontecres.wholphin.ui.formatDateTime
+import com.github.damontecres.wholphin.ui.getDateFormatter
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.playable
import com.github.damontecres.wholphin.ui.roundMinutes
@@ -28,11 +29,14 @@ import java.util.Locale
import java.util.UUID
import kotlin.time.Duration
+/**
+ * Wrapper for [BaseItemDto] with shortcuts for various UI elements
+ */
@Serializable
@Stable
data class BaseItem(
val data: BaseItemDto,
- val useSeriesForPrimary: Boolean,
+ val useSeriesForPrimary: Boolean = false,
val imageUrlOverride: String? = null,
val destinationOverride: Destination? = null,
) : CardGridItem {
@@ -57,6 +61,7 @@ data class BaseItem(
when (type) {
BaseItemKind.EPISODE -> data.seasonEpisode + " - " + name
BaseItemKind.SERIES -> data.seriesProductionYears
+ BaseItemKind.AUDIO -> listOfNotNull(data.album, artistsString).joinToString(" - ")
else -> data.productionYear?.toString()
}
@@ -74,8 +79,7 @@ data class BaseItem(
val canDelete: Boolean get() = data.canDelete == true
- @Transient
- val aspectRatio: Float? = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 }
+ val aspectRatio: Float? get() = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 }
val indexNumber get() = data.indexNumber
@@ -87,9 +91,11 @@ data class BaseItem(
val favorite get() = data.userData?.isFavorite ?: false
- @Transient
- val timeRemainingOrRuntime: Duration? = data.timeRemaining ?: data.runTimeTicks?.ticks
+ val timeRemainingOrRuntime: Duration? get() = data.timeRemaining ?: data.runTimeTicks?.ticks
+ /**
+ * Contains pre computed UI elements that would be expensive to create on the main thread
+ */
@Transient
val ui =
BaseItemUi(
@@ -116,7 +122,7 @@ data class BaseItem(
buildList {
if (type == BaseItemKind.EPISODE) {
data.seasonEpisode?.let(::add)
- data.premiereDate?.let { add(DateFormatter.format(it)) }
+ data.premiereDate?.let { add(getDateFormatter().format(it)) }
} else if (type == BaseItemKind.SERIES) {
data.seriesProductionYears?.let(::add)
} else if (type == BaseItemKind.PHOTO) {
@@ -125,6 +131,9 @@ data class BaseItem(
} else if (data.premiereDate != null) {
add(data.premiereDate!!.toLocalDate().toString())
}
+ } else if (type == BaseItemKind.BOX_SET) {
+ data.productionYear?.let { add(it.toString()) }
+ data.childCount?.let { add("$it items") }
} else {
data.productionYear?.let { add(it.toString()) }
}
@@ -173,6 +182,9 @@ data class BaseItem(
it.dayOfMonth.toString().padStart(2, '0')
}?.toIntOrNull()
+ /**
+ * Convert this [BaseItem] into a [Destination] to navigate to its page in the app
+ */
fun destination(index: Int? = null): Destination {
if (destinationOverride != null) return destinationOverride
val result =
@@ -223,6 +235,7 @@ data class BaseItem(
}
companion object {
+ @Deprecated("Use regular constructor instead")
fun from(
dto: BaseItemDto,
api: ApiClient,
@@ -244,6 +257,9 @@ data class BaseItemUi(
val quickDetails: AnnotatedString,
)
+/**
+ * Create the special [Destination.FilteredCollection] for the given genre information
+ */
fun createGenreDestination(
genreId: UUID,
genreName: String,
@@ -252,6 +268,7 @@ fun createGenreDestination(
includeItemTypes: List?,
) = Destination.FilteredCollection(
itemId = parentId,
+ parentType = BaseItemKind.GENRE,
filter =
CollectionFolderFilter(
nameOverride =
@@ -268,3 +285,31 @@ fun createGenreDestination(
),
recursive = true,
)
+
+fun createStudioDestination(
+ studioId: UUID,
+ name: String,
+ parentId: UUID,
+ parentName: String?,
+ includeItemTypes: List?,
+) = 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()
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt
index 3444ac54..3e07964c 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt
@@ -7,6 +7,9 @@ import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.extensions.ticks
import kotlin.time.Duration
+/**
+ * Represents a chapter within a video
+ */
data class Chapter(
val name: String?,
val position: Duration,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt
index 0cf88ff1..db03ff48 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt
@@ -16,6 +16,9 @@ import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.time.LocalDate
import java.util.UUID
+/**
+ * The type of a Seerr/Discover object with mapping to the Jellyfin [BaseItemKind]
+ */
@Serializable
enum class SeerrItemType(
val baseItemKind: BaseItemKind?,
@@ -46,6 +49,9 @@ enum class SeerrItemType(
}
}
+/**
+ * How available is a particular discovered item within the Jellyfin server
+ */
@Serializable
enum class SeerrAvailability(
val status: Int,
@@ -64,7 +70,7 @@ enum class SeerrAvailability(
}
/**
- * An item provided by a discovery service (ie Seerr). It may exist on the JF server as well.
+ * An item provided by a discovery service (ie Seerr). It may exist on the JF server as well, see [availability].
*/
@Stable
@Serializable
@@ -104,6 +110,9 @@ data class DiscoverItem(
}
}
+/**
+ * A rating for a discovered item which is usually fetched separately from the item
+ */
data class DiscoverRating(
val criticRating: Int?,
val audienceRating: Float?,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt
index f819c7c3..d0d32988 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt
@@ -14,6 +14,9 @@ import org.jellyfin.sdk.model.api.request.GetPersonsRequest
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID
+/**
+ * Filter for a collection folder
+ */
@Serializable
data class CollectionFolderFilter(
val nameOverride: String? = null,
@@ -24,6 +27,9 @@ data class CollectionFolderFilter(
val useSavedLibraryDisplayInfo: Boolean = true,
)
+/**
+ * A sort of simplified filter which can be [applyTo] a [GetItemsRequest] or [GetPersonsRequest] to add or remove filters
+ */
@Serializable
data class GetItemsFilter(
val favorite: Boolean? = null,
@@ -52,7 +58,7 @@ data class GetItemsFilter(
}
/**
- * Clear all of the values for the given filters
+ * Clear all the values for the given filters
*/
fun delete(filterOptions: List>): GetItemsFilter {
var newFilter = this
@@ -128,6 +134,9 @@ data class GetItemsFilter(
isFavorite = favorite,
)
+ /**
+ * Merge another [GetItemsFilter] onto this one, replacing only unset values
+ */
fun merge(filter: GetItemsFilter): GetItemsFilter =
this.copy(
favorite = favorite ?: filter.favorite,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt
index 09622733..74a6c6d1 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt
@@ -90,6 +90,18 @@ sealed interface HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): Genres = this.copy(viewOptions = viewOptions)
}
+ /**
+ * Row of a studios in a library
+ */
+ @Serializable
+ @SerialName("Studios")
+ data class Studios(
+ val parentId: UUID,
+ override val viewOptions: HomeRowViewOptions = HomeRowViewOptions.genreDefault,
+ ) : HomeRowConfig {
+ override fun updateViewOptions(viewOptions: HomeRowViewOptions): Studios = this.copy(viewOptions = viewOptions)
+ }
+
/**
* Favorites for a specific type
*/
@@ -162,7 +174,7 @@ sealed interface HomeRowConfig {
@SerialName("ByParent")
data class ByParent(
val parentId: UUID,
- val recursive: Boolean,
+ val recursive: Boolean = false,
val sort: SortAndDirection? = null,
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowConfig {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt
index 94a30475..b31261fd 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt
@@ -13,6 +13,10 @@ import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID
+/**
+ * Store the media source and audio/subtitle tracks chosen for a specific media item
+ *
+ */
@Entity(
foreignKeys = [
ForeignKey(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemTrackModification.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemTrackModification.kt
index e8bbfeb4..3888e438 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemTrackModification.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemTrackModification.kt
@@ -9,6 +9,11 @@ import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID
+/**
+ * Store modifications to an audio/subtitle track in a media item
+ *
+ * For example, the subtitle delay
+ */
@Entity(
foreignKeys = [
ForeignKey(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt
index 2c516272..0b1759ee 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt
@@ -17,6 +17,9 @@ import org.jellyfin.sdk.model.ServerVersion
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID
+/**
+ * Represents a Jellyfin server
+ */
@Entity(tableName = "servers")
@Serializable
data class JellyfinServer(
@@ -29,6 +32,9 @@ data class JellyfinServer(
val serverVersion: ServerVersion? by lazy { version?.let(ServerVersion::fromString) }
}
+/**
+ * Represents a Jellyfin user for a particular server
+ */
@Entity(
tableName = "users",
foreignKeys = [
@@ -59,6 +65,9 @@ data class JellyfinUser(
"JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()})"
}
+/**
+ * Represents the relationship between [JellyfinServer] and its [JellyfinUser]
+ */
data class JellyfinServerUsers(
@Embedded val server: JellyfinServer,
@Relation(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/LibraryDisplayInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/LibraryDisplayInfo.kt
index eb8ffdd1..edfc797a 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/LibraryDisplayInfo.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/LibraryDisplayInfo.kt
@@ -9,13 +9,20 @@ import androidx.room.Ignore
import androidx.room.Index
import com.github.damontecres.wholphin.ui.components.ViewOptions
import com.github.damontecres.wholphin.ui.data.SortAndDirection
+import com.github.damontecres.wholphin.ui.toServerString
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.serializer.UUIDSerializer
+import java.util.UUID
+/**
+ * Stores the filter, sort, and view options a user changes for a library
+ *
+ * This allows for restoring these settings whenever the user navigates to the library
+ */
@Entity(
foreignKeys = [
ForeignKey(
@@ -41,4 +48,13 @@ data class LibraryDisplayInfo(
) {
@Ignore @Transient
val sortAndDirection = SortAndDirection(sort, direction)
+
+ constructor(
+ user: JellyfinUser,
+ itemId: UUID,
+ sort: ItemSortBy,
+ direction: SortOrder,
+ filter: GetItemsFilter,
+ viewOptions: ViewOptions?,
+ ) : this(user.rowId, itemId.toServerString(), sort, direction, filter, viewOptions)
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/ServerPreferences.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/NavDrawerPinnedItem.kt
similarity index 85%
rename from app/src/main/java/com/github/damontecres/wholphin/data/model/ServerPreferences.kt
rename to app/src/main/java/com/github/damontecres/wholphin/data/model/NavDrawerPinnedItem.kt
index 2b503023..e312a2b1 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/ServerPreferences.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/NavDrawerPinnedItem.kt
@@ -9,6 +9,9 @@ enum class NavPinType {
UNPINNED,
}
+/**
+ * Stores preference information about nav drawer items such as its order and whether to show or put in More
+ */
@Entity(
foreignKeys = [
ForeignKey(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt
index ac4a4710..4e639009 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt
@@ -12,6 +12,9 @@ import org.jellyfin.sdk.model.api.BaseItemPerson
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.PersonKind
+/**
+ * Represents a person in some media such as an actor or director
+ */
@Stable
data class Person(
val id: UUID,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackEffect.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackEffect.kt
index 3c5cf6a0..0e7bcd37 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackEffect.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackEffect.kt
@@ -5,6 +5,9 @@ import androidx.room.Entity
import org.jellyfin.sdk.model.api.BaseItemKind
import java.util.UUID
+/**
+ * Store effects applied to some media such as color or hue adjustments
+ */
@Entity(tableName = "playback_effects", primaryKeys = ["jellyfinUserRowId", "itemId", "type"])
data class PlaybackEffect(
val jellyfinUserRowId: Int,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt
index f452e2f1..fb54dfcd 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt
@@ -9,6 +9,10 @@ import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID
+/**
+ * Stores the language choices for a series so they can be applied automatically to other episodes
+ * without the user needing to explicitly choose the tracks
+ */
@Entity(
foreignKeys = [
ForeignKey(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt
index c4cd3792..698a5f34 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt
@@ -6,6 +6,11 @@ import androidx.compose.runtime.setValue
import org.jellyfin.sdk.model.api.MediaType
import java.util.UUID
+/**
+ * Tracks playback of multiple items. Points to the current media with function to advance or go to previous ones.
+ *
+ * This is not the same thing as a Jellyfin server playlist
+ */
class Playlist(
items: List,
startIndex: Int = 0,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt
index 1e9f963d..a6886cf2 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt
@@ -2,6 +2,9 @@ package com.github.damontecres.wholphin.data.model
import com.github.damontecres.wholphin.services.SeerrUserConfig
+/**
+ * Permission levels for a user on a Seerr server
+ */
enum class SeerrPermission(
private val flag: Int,
) {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServer.kt
index e29305d2..311379e4 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServer.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServer.kt
@@ -13,6 +13,9 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.serializer.UUIDSerializer
+/**
+ * Represents a Seerr server instance
+ */
@Entity(
tableName = "seerr_servers",
indices = [Index("url", unique = true)],
@@ -26,6 +29,9 @@ data class SeerrServer(
val version: String? = null,
)
+/**
+ * Represents a user on a [SeerrServer]
+ */
@Entity(
tableName = "seerr_users",
foreignKeys = [
@@ -56,12 +62,18 @@ data class SeerrUser(
"SeerrUser(jellyfinUserRowId=$jellyfinUserRowId, serverId=$serverId, authMethod=$authMethod, username=$username, password?=${password.isNotNullOrBlank()}, credential?=${credential.isNotNullOrBlank()})"
}
+/**
+ * The method used to authenticate a user to the server
+ */
enum class SeerrAuthMethod {
LOCAL,
JELLYFIN,
API_KEY,
}
+/**
+ * Represents the relationship between a [SeerrServer] and its [SeerrUser]s
+ */
data class SeerrServerUsers(
@Embedded val server: SeerrServer,
@Relation(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Trailer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Trailer.kt
index 943789db..01e473b2 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Trailer.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Trailer.kt
@@ -1,9 +1,15 @@
package com.github.damontecres.wholphin.data.model
+/**
+ * Represents a trailer for media
+ */
sealed interface Trailer {
val name: String
}
+/**
+ * A [Trailer] stored on the Jellyfin server
+ */
data class LocalTrailer(
val baseItem: BaseItem,
) : Trailer {
@@ -11,6 +17,9 @@ data class LocalTrailer(
get() = baseItem.name ?: ""
}
+/**
+ * A [Trailer] available via a remote URL, such as YouTube
+ */
data class RemoteTrailer(
override val name: String,
val url: String,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/VideoFilter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/VideoFilter.kt
index e71db95c..93543e23 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/VideoFilter.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/VideoFilter.kt
@@ -14,7 +14,7 @@ import androidx.media3.effect.ScaleAndRotateTransformation
import androidx.room.Ignore
/**
- * Modifications to a video playback
+ * Modifications to an image or video playback
*/
data class VideoFilter(
val rotation: Int = 0,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt
index 12a1d529..5bf090f5 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt
@@ -5,6 +5,7 @@ import androidx.annotation.ArrayRes
import androidx.annotation.StringRes
import androidx.core.content.edit
import androidx.preference.PreferenceManager
+import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.services.UpdateChecker
@@ -62,7 +63,7 @@ sealed interface AppPreference {
AppSliderPreference(
title = R.string.skip_forward_preference,
defaultValue = 30,
- min = 10,
+ min = 5,
max = 5.minutes.inWholeSeconds,
interval = 5,
getter = {
@@ -464,16 +465,18 @@ sealed interface AppPreference {
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
- val DirectPlayAss =
- AppSwitchPreference(
- title = R.string.direct_play_ass,
- defaultValue = true,
- getter = { it.playbackPreferences.overrides.directPlayAss },
+ val AssSubtitleMode =
+ AppChoicePreference(
+ title = R.string.ass_subtitle_playback,
+ defaultValue = AssPlaybackMode.ASS_LIBASS,
+ getter = { it.playbackPreferences.overrides.assPlaybackMode },
setter = { prefs, value ->
- prefs.updatePlaybackOverrides { directPlayAss = value }
+ prefs.updatePlaybackOverrides { assPlaybackMode = value }
},
- summaryOn = R.string.enabled,
- summaryOff = R.string.disabled,
+ displayValues = R.array.ass_subtitle_modes,
+ subtitles = R.array.ass_subtitle_modes_summary,
+ indexToValue = { AssPlaybackMode.forNumber(it) },
+ valueToIndex = { if (it != AssPlaybackMode.UNRECOGNIZED) it.number else AssPlaybackMode.ASS_LIBASS.number },
)
val DirectPlayPgs =
AppSwitchPreference(
@@ -535,6 +538,18 @@ sealed interface AppPreference {
valueToIndex = { if (it != AppThemeColors.UNRECOGNIZED) it.number else 0 },
)
+ val ShowLogos =
+ AppSwitchPreference(
+ title = R.string.prefer_logos,
+ defaultValue = true,
+ getter = { it.interfacePreferences.showLogos },
+ setter = { prefs, value ->
+ prefs.updateInterfacePreferences { showLogos = value }
+ },
+ summaryOn = R.string.enabled,
+ summaryOff = R.string.disabled,
+ )
+
val InstalledVersion =
AppClickablePreference(
title = R.string.installed_version,
@@ -845,8 +860,19 @@ sealed interface AppPreference {
},
displayValues = R.array.player_backend_options,
subtitles = R.array.player_backend_options_subtitles,
- indexToValue = { PlayerBackend.forNumber(it) },
- valueToIndex = { it.number },
+ indexToValue = { PlayerBackend.forNumber(it) ?: PlayerBackend.EXO_PLAYER },
+ valueToIndex = { if (it != PlayerBackend.UNRECOGNIZED) it.number else PlayerBackend.EXO_PLAYER.number },
+ )
+
+ val ExternalPlayerApp =
+ AppStringPreference(
+ title = R.string.external_player,
+ defaultValue = "",
+ getter = { it.playbackPreferences.externalPlayer },
+ setter = { prefs, value ->
+ prefs.updatePlaybackPreferences { externalPlayer = value }
+ },
+ summary = null,
)
val ExoPlayerSettings =
@@ -1107,10 +1133,12 @@ val basicPreferences =
PreferenceGroup(
title = R.string.more,
preferences =
- listOf(
- AppPreference.SeerrIntegration,
- AppPreference.AdvancedSettings,
- ),
+ buildList {
+ if (BuildConfig.DISCOVER_ENABLED) {
+ add(AppPreference.SeerrIntegration)
+ }
+ add(AppPreference.AdvancedSettings)
+ },
),
)
@@ -1120,7 +1148,7 @@ private val ExoPlayerSettings =
AppPreference.DownMixStereo,
AppPreference.Ac3Supported,
AppPreference.PreferAc3ForSurround,
- AppPreference.DirectPlayAss,
+ AppPreference.AssSubtitleMode,
AppPreference.DirectPlayPgs,
AppPreference.DirectPlayDoviProfile7,
AppPreference.DecodeAv1,
@@ -1157,6 +1185,7 @@ val advancedPreferences =
preferences =
listOf(
AppPreference.ShowClock,
+ AppPreference.ShowLogos,
AppPreference.ManageMedia,
AppPreference.CombineContinueNext,
// Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418
@@ -1216,6 +1245,10 @@ val advancedPreferences =
AppPreference.MpvSettings,
),
),
+ ConditionalPreferences(
+ { it.playbackPreferences.playerBackend == PlayerBackend.EXTERNAL_PLAYER },
+ listOf(AppPreference.ExternalPlayerApp),
+ ),
),
),
)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt
index bf8ab2a4..9b46de83 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt
@@ -61,10 +61,11 @@ class AppPreferencesSerializer
.apply {
ac3Supported = AppPreference.Ac3Supported.defaultValue
downmixStereo = AppPreference.DownMixStereo.defaultValue
- directPlayAss = AppPreference.DirectPlayAss.defaultValue
+// directPlayAss = AppPreference.DirectPlayAss.defaultValue
directPlayPgs = AppPreference.DirectPlayPgs.defaultValue
mediaExtensionsEnabled =
AppPreference.FfmpegPreference.defaultValue
+ assPlaybackMode = AppPreference.AssSubtitleMode.defaultValue
}.build()
mpvOptions =
@@ -95,6 +96,7 @@ class AppPreferencesSerializer
AppPreference.NavDrawerSwitchOnFocus.defaultValue
showClock = AppPreference.ShowClock.defaultValue
backdropStyle = AppPreference.BackdropStylePref.defaultValue
+ showLogos = AppPreference.ShowLogos.defaultValue
subtitlesPreferences =
SubtitlePreferences
@@ -152,6 +154,15 @@ class AppPreferencesSerializer
slideshowDuration = AppPreference.SlideshowDuration.defaultValue
slideshowPlayVideos = AppPreference.SlideshowPlayVideos.defaultValue
}.build()
+
+ musicPreferences =
+ MusicPreferences
+ .newBuilder()
+ .apply {
+ showBackdrop = true
+ showLyrics = true
+ showAlbumArt = true
+ }.build()
}.build()
override suspend fun readFrom(input: InputStream): AppPreferences {
@@ -220,6 +231,11 @@ inline fun AppPreferences.updateScreensaverPreferences(block: ScreensaverPrefere
screensaverPreference = screensaverPreference.toBuilder().apply(block).build()
}
+inline fun AppPreferences.updateMusicPreferences(block: MusicPreferences.Builder.() -> Unit): AppPreferences =
+ update {
+ musicPreferences = musicPreferences.toBuilder().apply(block).build()
+ }
+
fun SubtitlePreferences.Builder.resetSubtitles() {
fontSize = SubtitleSettings.FontSize.defaultValue.toInt()
fontColor = SubtitleSettings.FontColor.defaultValue.toArgb()
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt
index ca720af3..8899fe8e 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt
@@ -1,11 +1,11 @@
package com.github.damontecres.wholphin.services
import android.content.Context
+import android.content.pm.PackageInfo
import android.os.Build
import androidx.core.content.edit
import androidx.datastore.core.DataStore
import androidx.preference.PreferenceManager
-import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.data.SeerrServerDao
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
@@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.preferences.updateHomePagePreferences
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences
import com.github.damontecres.wholphin.preferences.updateMpvOptions
+import com.github.damontecres.wholphin.preferences.updateMusicPreferences
import com.github.damontecres.wholphin.preferences.updatePhotoPreferences
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
import com.github.damontecres.wholphin.preferences.updateScreensaverPreferences
@@ -31,6 +32,9 @@ import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
+/**
+ * Handles any changes needed when the app in upgraded on the device such as setting new preferences
+ */
@Singleton
class AppUpgradeHandler
@Inject
@@ -39,12 +43,10 @@ class AppUpgradeHandler
private val appPreferences: DataStore,
private val seerrServerDao: SeerrServerDao,
) {
- suspend fun run() {
- val pkgInfo =
- WholphinApplication.instance.packageManager.getPackageInfo(
- WholphinApplication.instance.packageName,
- 0,
- )
+ val pkgInfo: PackageInfo get() = context.packageManager.getPackageInfo(context.packageName, 0)
+ val currentVersion: Version get() = Version.fromString(pkgInfo.versionName!!)
+
+ fun needUpgrade(): Boolean {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
val previousVersion = prefs.getString(VERSION_NAME_CURRENT_KEY, null)
val previousVersionCode = prefs.getLong(VERSION_CODE_CURRENT_KEY, -1)
@@ -56,32 +58,48 @@ class AppUpgradeHandler
} else {
pkgInfo.versionCode.toLong()
}
- if (newVersion != previousVersion || newVersionCode != previousVersionCode) {
- Timber.i(
- "App updated: $previousVersion=>$newVersion, $previousVersionCode=>$newVersionCode",
- )
- 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")
- }
+ Timber.i(
+ "App versions: $previousVersion=>$newVersion, $previousVersionCode=>$newVersionCode",
+ )
+ return newVersion != previousVersion || newVersionCode != previousVersionCode
}
+ suspend fun run() {
+ Timber.i("App upgrade started")
+ val prefs = PreferenceManager.getDefaultSharedPreferences(context)
+ val previousVersion = prefs.getString(VERSION_NAME_CURRENT_KEY, null)
+ val previousVersionCode = prefs.getLong(VERSION_CODE_CURRENT_KEY, -1)
+
+ val newVersion = pkgInfo.versionName!!
+ val newVersionCode =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ pkgInfo.longVersionCode
+ } else {
+ pkgInfo.versionCode.toLong()
+ }
+ // Store the previous and new version info
+ prefs.edit(true) {
+ putString(VERSION_NAME_PREVIOUS_KEY, previousVersion)
+ putLong(VERSION_CODE_PREVIOUS_KEY, previousVersionCode)
+ 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) {
try {
val fontFileName = "subfont.ttf"
@@ -110,6 +128,9 @@ class AppUpgradeHandler
const val VERSION_CODE_CURRENT_KEY = "version.current.code"
}
+ /**
+ * Perform any needed upgrades
+ */
suspend fun upgradeApp(
previous: Version,
current: Version,
@@ -120,7 +141,7 @@ class AppUpgradeHandler
it.updatePlaybackOverrides {
ac3Supported = AppPreference.Ac3Supported.defaultValue
downmixStereo = AppPreference.DownMixStereo.defaultValue
- directPlayAss = AppPreference.DirectPlayAss.defaultValue
+// directPlayAss = AppPreference.DirectPlayAss.defaultValue
directPlayPgs = AppPreference.DirectPlayPgs.defaultValue
}
}
@@ -280,5 +301,31 @@ class AppUpgradeHandler
)
}
}
+
+ if (previous.isEqualOrBefore(Version.fromString("0.5.4-6-g0"))) {
+ appPreferences.updateData {
+ it.updateMusicPreferences {
+ showBackdrop = true
+ showLyrics = true
+ showAlbumArt = true
+ }
+ }
+ }
+
+ if (previous.isEqualOrBefore(Version.fromString("0.5.4-15-g0"))) {
+ appPreferences.updateData {
+ it.updateInterfacePreferences {
+ showLogos = AppPreference.ShowLogos.defaultValue
+ }
+ }
+ }
+
+ if (previous.isEqualOrBefore(Version.fromString("0.6.2-1-g0"))) {
+ appPreferences.updateData {
+ it.updatePlaybackOverrides {
+ assPlaybackMode = AppPreference.AssSubtitleMode.defaultValue
+ }
+ }
+ }
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt
index e0c48cca..1bf34929 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt
@@ -32,6 +32,11 @@ import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
+/**
+ * Stores state for the backdrop of the app shown on non-full screen pages
+ *
+ * This is usually the backdrop of the currently focused media
+ */
@Singleton
@OptIn(FlowPreview::class)
class BackdropService
@@ -46,6 +51,9 @@ class BackdropService
private val _backdropFlow = MutableStateFlow(BackdropResult.NONE)
val backdropFlow = _backdropFlow
+ /**
+ * Update the backdrop to use the specified item
+ */
suspend fun submit(item: BaseItem) =
withContext(Dispatchers.IO) {
val imageUrl =
@@ -57,8 +65,14 @@ class BackdropService
submit(item.id.toString(), imageUrl)
}
+ /**
+ * Update the backdrop to use the specified discovered item
+ */
suspend fun submit(item: DiscoverItem) = submit("discover_${item.id}", item.backDropUrl)
+ /**
+ * Update the backdrop to use the specified URL
+ */
suspend fun submit(
itemId: String,
imageUrl: String?,
@@ -74,6 +88,9 @@ class BackdropService
}
}
+ /**
+ * Remove the backdrop, such as when switching pages
+ */
suspend fun clearBackdrop() {
_backdropFlow.update {
BackdropResult.NONE
@@ -114,7 +131,7 @@ class BackdropService
}
}
- private suspend fun extractColorsFromBackdrop(imageUrl: String?): ExtractedColors =
+ suspend fun extractColorsFromBackdrop(imageUrl: String?): ExtractedColors =
withContext(Dispatchers.IO) {
if (imageUrl.isNullOrBlank()) {
return@withContext ExtractedColors.DEFAULT
@@ -224,6 +241,9 @@ class BackdropService
}
}
+/**
+ * The result from determining the backdrop URL and extracted colors for the dynamic backdrop
+ */
data class BackdropResult(
val itemId: String?,
val imageUrl: String?,
@@ -245,6 +265,9 @@ data class BackdropResult(
}
}
+/**
+ * The colors extracted from an image
+ */
data class ExtractedColors(
val primary: Color,
val secondary: Color,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt
index 205e12bf..9c4cfa5c 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt
@@ -27,6 +27,9 @@ import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
+/**
+ * Caches when media was lasted played. This is mostly used by the combined Continue Watching & Next Up home row.
+ */
@Singleton
class DatePlayedService
@Inject
@@ -80,6 +83,12 @@ class DatePlayedService
}
}
+ /**
+ * Get the logical timestamp when the item was last played.
+ *
+ * This is calculated using lastest of the actual last played timestamp of the item,
+ * the previous episode's last played timestamp, and the episode's premiere date
+ */
suspend fun getLastPlayed(item: BaseItem): LocalDateTime? =
withContext(Dispatchers.IO) {
val seriesId = item.data.seriesId
@@ -93,6 +102,11 @@ class DatePlayedService
}
}
+ /**
+ * Remove the cached last date played for the given item's series
+ *
+ * Used for when a user plays this item
+ */
fun invalidate(item: BaseItem) {
item.data.seriesId?.let { seriesId ->
Timber.d("Invalidating %s", seriesId)
@@ -100,6 +114,9 @@ class DatePlayedService
}
}
+ /**
+ * Remove the cached last data played for the given item or its series
+ */
suspend fun invalidate(itemId: UUID) {
val seriesId =
api.userLibraryApi.getItem(itemId = itemId).content.let {
@@ -121,6 +138,9 @@ class DatePlayedService
}
}
+/**
+ * Invalidates the date played cached when the current user changes
+ */
@ActivityScoped
class DatePlayedInvalidationService
@Inject
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt
index dd037159..d3f920de 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt
@@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.services
import android.content.Context
+import com.github.damontecres.wholphin.preferences.AssPlaybackMode
import com.github.damontecres.wholphin.preferences.PlaybackPreferences
import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest
import com.github.damontecres.wholphin.util.profile.createDeviceProfile
@@ -14,6 +15,9 @@ import org.jellyfin.sdk.model.api.DeviceProfile
import javax.inject.Inject
import javax.inject.Singleton
+/**
+ * Creates and caches the device direct play/transcoding profile sent to the server for ExoPlayer
+ */
@Singleton
class DeviceProfileService
@Inject
@@ -21,7 +25,7 @@ class DeviceProfileService
@param:ApplicationContext private val context: Context,
) {
val mediaCodecCapabilitiesTest by lazy {
- // Created lazily below on the IO thread since it cn take time
+ // Created lazily below on another thread since it cn take time
MediaCodecCapabilitiesTest(context)
}
private val mutex = Mutex()
@@ -33,14 +37,14 @@ class DeviceProfileService
prefs: PlaybackPreferences,
serverVersion: ServerVersion?,
): DeviceProfile =
- withContext(Dispatchers.IO) {
+ withContext(Dispatchers.Default) {
mutex.withLock {
val newConfig =
DeviceProfileConfiguration(
maxBitrate = prefs.maxBitrate.toInt(),
isAC3Enabled = prefs.overrides.ac3Supported,
downMixAudio = prefs.overrides.downmixStereo,
- assDirectPlay = prefs.overrides.directPlayAss,
+ assPlaybackMode = prefs.overrides.assPlaybackMode,
pgsDirectPlay = prefs.overrides.directPlayPgs,
dolbyVisionELDirectPlay = prefs.overrides.directPlayDolbyVisionEL,
decodeAv1 = prefs.overrides.decodeAv1,
@@ -56,7 +60,7 @@ class DeviceProfileService
maxBitrate = newConfig.maxBitrate,
isAC3Enabled = newConfig.isAC3Enabled,
downMixAudio = newConfig.downMixAudio,
- assDirectPlay = newConfig.assDirectPlay,
+ assDirectPlay = newConfig.assPlaybackMode != AssPlaybackMode.ASS_TRANSCODE,
pgsDirectPlay = newConfig.pgsDirectPlay,
dolbyVisionELDirectPlay = newConfig.dolbyVisionELDirectPlay,
decodeAv1 = prefs.overrides.decodeAv1,
@@ -76,7 +80,7 @@ data class DeviceProfileConfiguration(
val maxBitrate: Int,
val isAC3Enabled: Boolean,
val downMixAudio: Boolean,
- val assDirectPlay: Boolean,
+ val assPlaybackMode: AssPlaybackMode,
val pgsDirectPlay: Boolean,
val dolbyVisionELDirectPlay: Boolean,
val decodeAv1: Boolean,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DisplayPreferencesService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DisplayPreferencesService.kt
new file mode 100644
index 00000000..d5691fe6
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/DisplayPreferencesService.kt
@@ -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.() -> 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"
+ }
+ }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ExtrasService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ExtrasService.kt
index e952177b..65b74083 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/ExtrasService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/ExtrasService.kt
@@ -9,12 +9,20 @@ import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
+/**
+ * Get extras for media
+ *
+ * @see [ExtrasItem]
+ */
@Singleton
class ExtrasService
@Inject
constructor(
private val api: ApiClient,
) {
+ /**
+ * Get the [ExtrasItem]s for the given item
+ */
suspend fun getExtras(itemId: UUID): List {
val extrasMap =
api.userLibraryApi
@@ -42,6 +50,9 @@ class ExtrasService
}
}
+/**
+ * The order which extras should be shown
+ */
private val ExtraType.sortOrder: Int
get() =
when (this) {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/FavoriteWatchManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/FavoriteWatchManager.kt
index a68a6fe5..93b3ebe4 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/FavoriteWatchManager.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/FavoriteWatchManager.kt
@@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.services
+import com.github.damontecres.wholphin.data.model.BaseItem
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.playStateApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
@@ -8,6 +9,9 @@ import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
+/**
+ * Handles toggling media as favorited or watched
+ */
@Singleton
class FavoriteWatchManager
@Inject
@@ -36,4 +40,7 @@ class FavoriteWatchManager
} else {
api.userLibraryApi.unmarkFavoriteItem(itemId).content
}
+
+ suspend fun removeContinueWatching(item: BaseItem) {
+ }
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt
index b8a850ef..784440aa 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt
@@ -8,6 +8,7 @@ import com.github.damontecres.wholphin.data.model.HomePageSettings
import com.github.damontecres.wholphin.data.model.HomeRowConfig
import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION
import com.github.damontecres.wholphin.data.model.createGenreDestination
+import com.github.damontecres.wholphin.data.model.createStudioDestination
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
import com.github.damontecres.wholphin.preferences.HomePagePreferences
import com.github.damontecres.wholphin.ui.DefaultItemFields
@@ -21,6 +22,7 @@ import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetPersonsHandler
+import com.github.damontecres.wholphin.util.GetStudiosRequestHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.HomeRowLoadingState.Error
import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success
@@ -40,7 +42,6 @@ import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.jellyfin.sdk.api.client.ApiClient
-import org.jellyfin.sdk.api.client.extensions.displayPreferencesApi
import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
@@ -58,11 +59,15 @@ import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
import org.jellyfin.sdk.model.api.request.GetRecommendedProgramsRequest
import org.jellyfin.sdk.model.api.request.GetRecordingsRequest
+import org.jellyfin.sdk.model.api.request.GetStudiosRequest
import timber.log.Timber
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
+/**
+ * Handles getting home page settings and data
+ */
@Singleton
class HomeSettingsService
@Inject
@@ -75,6 +80,7 @@ class HomeSettingsService
private val latestNextUpService: LatestNextUpService,
private val imageUrlService: ImageUrlService,
private val suggestionService: SuggestionService,
+ private val displayPreferencesService: DisplayPreferencesService,
) {
@OptIn(ExperimentalSerializationApi::class)
val jsonParser =
@@ -84,6 +90,9 @@ class HomeSettingsService
allowTrailingComma = true
}
+ /**
+ * The current home page settings
+ */
val currentSettings = MutableStateFlow(HomePageResolvedSettings.EMPTY)
/**
@@ -94,19 +103,11 @@ class HomeSettingsService
suspend fun saveToServer(
userId: UUID,
settings: HomePageSettings,
- displayPreferencesId: String = DISPLAY_PREF_ID,
+ displayPreferencesId: String = DisplayPreferencesService.DEFAULT_DISPLAY_PREF_ID,
) {
- val current = getDisplayPreferences(userId, DISPLAY_PREF_ID)
- val customPrefs =
- current.customPrefs.toMutableMap().apply {
- 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),
- )
+ displayPreferencesService.updateDisplayPreferences(userId, displayPreferencesId) {
+ put(CUSTOM_PREF_ID, jsonParser.encodeToString(settings))
+ }
}
/**
@@ -118,24 +119,15 @@ class HomeSettingsService
*/
suspend fun loadFromServer(
userId: UUID,
- displayPreferencesId: String = DISPLAY_PREF_ID,
- ): HomePageSettings? {
- val current = getDisplayPreferences(userId, displayPreferencesId)
- return current.customPrefs[CUSTOM_PREF_ID]?.let {
- 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
+ displayPreferencesId: String = DisplayPreferencesService.DEFAULT_DISPLAY_PREF_ID,
+ ): HomePageSettings? =
+ displayPreferencesService
+ .getDisplayPreferences(userId, displayPreferencesId)
+ .customPrefs[CUSTOM_PREF_ID]
+ ?.let {
+ val jsonElement = jsonParser.parseToJsonElement(it)
+ decode(jsonElement)
+ }
/**
* Computes the filename for locally saved [HomePageSettings]
@@ -245,6 +237,9 @@ class HomeSettingsService
currentSettings.update { resolvedSettings }
}
+ /**
+ * Resolve the settings and set them to be the current settings
+ */
suspend fun updateCurrent(settings: HomePageSettings) {
val resolvedRows =
settings.rows.mapIndexed { index, config ->
@@ -317,14 +312,17 @@ class HomeSettingsService
return HomePageResolvedSettings(rowConfig)
}
+ /**
+ * Create home page settings from the user's web UI home page settings
+ */
suspend fun parseFromWebConfig(userId: UUID): HomePageResolvedSettings? {
val customPrefs =
- api.displayPreferencesApi
+ displayPreferencesService
.getDisplayPreferences(
displayPreferencesId = "usersettings",
userId = userId,
client = "emby",
- ).content.customPrefs
+ ).customPrefs
val userDto by api.userApi.getUserById(userId)
val config = userDto.configuration ?: DefaultUserConfiguration
val libraries =
@@ -438,10 +436,7 @@ class HomeSettingsService
): HomeRowConfigDisplay =
when (config) {
is HomeRowConfig.ByParent -> {
- val name =
- api.userLibraryApi
- .getItem(itemId = config.parentId)
- .content.name ?: ""
+ val name = getItemName(config.parentId) ?: ""
HomeRowConfigDisplay(
id,
name,
@@ -466,10 +461,7 @@ class HomeSettingsService
}
is HomeRowConfig.Genres -> {
- val name =
- api.userLibraryApi
- .getItem(itemId = config.parentId)
- .content.name ?: ""
+ val name = getItemName(config.parentId) ?: ""
HomeRowConfigDisplay(
id,
context.getString(R.string.genres_in, name),
@@ -477,6 +469,15 @@ class HomeSettingsService
)
}
+ is HomeRowConfig.Studios -> {
+ val name = getItemName(config.parentId) ?: ""
+ HomeRowConfigDisplay(
+ id,
+ context.getString(R.string.studios_in, name),
+ config,
+ )
+ }
+
is HomeRowConfig.GetItems -> {
HomeRowConfigDisplay(id, config.name, config)
}
@@ -490,10 +491,7 @@ class HomeSettingsService
}
is HomeRowConfig.RecentlyAdded -> {
- val name =
- api.userLibraryApi
- .getItem(itemId = config.parentId)
- .content.name ?: ""
+ val name = getItemName(config.parentId) ?: ""
HomeRowConfigDisplay(
id,
context.getString(R.string.recently_added_in, name),
@@ -502,10 +500,7 @@ class HomeSettingsService
}
is HomeRowConfig.RecentlyReleased -> {
- val name =
- api.userLibraryApi
- .getItem(itemId = config.parentId)
- .content.name ?: ""
+ val name = getItemName(config.parentId) ?: ""
HomeRowConfigDisplay(
id,
context.getString(R.string.recently_released_in, name),
@@ -547,10 +542,7 @@ class HomeSettingsService
}
is HomeRowConfig.Suggestions -> {
- val name =
- api.userLibraryApi
- .getItem(itemId = config.parentId)
- .content.name ?: ""
+ val name = getItemName(config.parentId) ?: ""
HomeRowConfigDisplay(
id = id,
title = context.getString(R.string.suggestions_for, name),
@@ -559,6 +551,16 @@ class HomeSettingsService
}
}
+ private suspend fun getItemName(itemId: UUID): String? =
+ try {
+ api.userLibraryApi
+ .getItem(itemId = itemId)
+ .content.name
+ } catch (ex: Exception) {
+ Timber.e(ex, "Could not get name for %s", itemId)
+ context.getString(R.string.unknown)
+ }
+
/**
* Fetch the data from the server for a given [HomeRowConfig]
*/
@@ -585,6 +587,7 @@ class HomeSettingsService
title = context.getString(R.string.continue_watching),
items = resume,
viewOptions = row.viewOptions,
+ rowType = row,
)
}
@@ -603,6 +606,7 @@ class HomeSettingsService
title = context.getString(R.string.next_up),
items = nextUp,
viewOptions = row.viewOptions,
+ rowType = row,
)
}
@@ -632,6 +636,7 @@ class HomeSettingsService
nextUp,
),
viewOptions = row.viewOptions,
+ rowType = row,
)
}
@@ -691,6 +696,58 @@ class HomeSettingsService
title,
genres,
viewOptions = row.viewOptions,
+ rowType = row,
+ )
+ }
+
+ is HomeRowConfig.Studios -> {
+ val request =
+ GetStudiosRequest(
+ parentId = row.parentId,
+ userId = userDto.id,
+ limit = limit,
+ includeItemTypes = listOf(BaseItemKind.SERIES),
+ )
+ val items =
+ GetStudiosRequestHandler
+ .execute(api, request)
+ .content.items
+ val library =
+ libraries
+ .firstOrNull { it.itemId == row.parentId }
+ val title =
+ library?.name?.let { context.getString(R.string.studios_in, it) }
+ ?: context.getString(R.string.studios)
+ val studios =
+ items.map {
+ val imageUrl =
+ imageUrlService.getItemImageUrl(
+ itemId = it.id,
+ imageType = ImageType.THUMB,
+ )
+ BaseItem(
+ it,
+ false,
+ imageUrl,
+ createStudioDestination(
+ studioId = it.id,
+ name = it.name ?: "",
+ parentId = row.parentId,
+ parentName = library?.name,
+ includeItemTypes =
+ library?.collectionType?.let {
+ getTypeFor(it)?.let {
+ listOf(it)
+ }
+ },
+ ),
+ )
+ }
+
+ Success(
+ title,
+ studios,
+ viewOptions = row.viewOptions,
)
}
@@ -718,6 +775,7 @@ class HomeSettingsService
title,
it,
row.viewOptions,
+ rowType = row,
)
}
latest
@@ -750,6 +808,7 @@ class HomeSettingsService
title,
it,
row.viewOptions,
+ rowType = row,
)
}
}
@@ -778,6 +837,7 @@ class HomeSettingsService
name ?: context.getString(R.string.collection),
it,
row.viewOptions,
+ rowType = row,
)
}
}
@@ -805,6 +865,7 @@ class HomeSettingsService
row.name,
it,
row.viewOptions,
+ rowType = row,
)
}
}
@@ -855,6 +916,7 @@ class HomeSettingsService
title,
it,
row.viewOptions,
+ rowType = row,
)
}
}
@@ -879,6 +941,7 @@ class HomeSettingsService
context.getString(R.string.active_recordings),
it,
row.viewOptions,
+ rowType = row,
)
}
}
@@ -891,7 +954,7 @@ class HomeSettingsService
limit = limit,
enableUserData = true,
enableImages = true,
- enableImageTypes = listOf(ImageType.PRIMARY),
+ enableImageTypes = listOf(ImageType.PRIMARY, ImageType.LOGO),
imageTypeLimit = 1,
)
api.liveTvApi
@@ -903,6 +966,7 @@ class HomeSettingsService
context.getString(R.string.live_tv),
it,
row.viewOptions,
+ rowType = row,
)
}
}
@@ -920,6 +984,7 @@ class HomeSettingsService
context.getString(R.string.channels),
it,
row.viewOptions,
+ rowType = row,
)
}
}
@@ -942,12 +1007,14 @@ class HomeSettingsService
title,
suggestions.items,
row.viewOptions,
+ rowType = row,
)
} else if (suggestions is SuggestionsResource.Empty) {
Success(
title,
listOf(),
row.viewOptions,
+ rowType = row,
)
} else {
Error(
@@ -959,7 +1026,6 @@ class HomeSettingsService
}
companion object {
- const val DISPLAY_PREF_ID = "default"
const val CUSTOM_PREF_ID = "home_settings"
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/KeyValueService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/KeyValueService.kt
new file mode 100644
index 00000000..543ea656
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/KeyValueService.kt
@@ -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,
+ ) {
+ val json =
+ Json {
+ ignoreUnknownKeys = true
+ isLenient = true
+ encodeDefaults = false
+ }
+
+ inline fun get(key: String): Flow =
+ dataStore.data.map { preferences ->
+ preferences[stringPreferencesKey(key)]?.let {
+ json.decodeFromString(serializer(), it)
+ }
+ }
+
+ inline fun get(
+ key: String,
+ defaultValue: T,
+ ): Flow =
+ dataStore.data.map { preferences ->
+ preferences[stringPreferencesKey(key)]?.let {
+ json.decodeFromString(serializer(), it)
+ } ?: defaultValue
+ }
+
+ inline fun get(
+ userId: UUID,
+ key: String,
+ defaultValue: T,
+ ): Flow =
+ dataStore.data.map { preferences ->
+ preferences[stringPreferencesKey("${userId}_$key")]?.let {
+ json.decodeFromString(serializer(), it)
+ } ?: defaultValue
+ }
+
+ suspend inline fun save(
+ key: String,
+ value: T,
+ ) {
+ dataStore.updateData { preferences ->
+ val valueStr = json.encodeToString(value)
+ preferences.toMutablePreferences().apply {
+ set(stringPreferencesKey(key), valueStr)
+ }
+ }
+ }
+
+ suspend inline fun save(
+ userId: UUID,
+ key: String,
+ value: T,
+ ) {
+ dataStore.updateData { preferences ->
+ val valueStr = json.encodeToString(value)
+ preferences.toMutablePreferences().apply {
+ set(stringPreferencesKey("${userId}_$key"), valueStr)
+ }
+ }
+ }
+ }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt
index e30a39ca..01bdba3f 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt
@@ -1,30 +1,32 @@
+@file:UseSerializers(
+ UUIDSerializer::class,
+ LocalDateTimeSerializer::class,
+)
+
package com.github.damontecres.wholphin.services
-import android.content.Context
-import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.SlimItemFields
-import com.github.damontecres.wholphin.util.HomeRowLoadingState
+import com.github.damontecres.wholphin.util.LocalDateTimeSerializer
import com.github.damontecres.wholphin.util.supportItemKinds
-import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.UseSerializers
+import kotlinx.serialization.json.Json
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
-import org.jellyfin.sdk.api.client.extensions.userLibraryApi
-import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.BaseItemKind
-import org.jellyfin.sdk.model.api.CollectionType
-import org.jellyfin.sdk.model.api.ImageType
-import org.jellyfin.sdk.model.api.UserDto
-import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
+import org.jellyfin.sdk.model.api.ItemSortBy
+import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
+import org.jellyfin.sdk.model.serializer.UUIDSerializer
import timber.log.Timber
import java.time.LocalDateTime
import java.util.UUID
@@ -32,14 +34,21 @@ import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds
+/**
+ * Get continue watching and next up items for users
+ */
@Singleton
class LatestNextUpService
@Inject
constructor(
- @param:ApplicationContext private val context: Context,
private val api: ApiClient,
private val datePlayedService: DatePlayedService,
+ private val displayPreferencesService: DisplayPreferencesService,
+ private val favoriteWatchManager: FavoriteWatchManager,
) {
+ /**
+ * Get resume (continue watching) items for a user
+ */
suspend fun getResume(
userId: UUID,
limit: Int,
@@ -61,12 +70,6 @@ class LatestNextUpService
remove(BaseItemKind.EPISODE)
}
},
- enableImageTypes =
- listOf(
- ImageType.PRIMARY,
- ImageType.THUMB,
- ImageType.BACKDROP,
- ),
)
val items =
api.itemsApi
@@ -77,6 +80,9 @@ class LatestNextUpService
return items
}
+ /**
+ * Get next up items for a user
+ */
suspend fun getNextUp(
userId: UUID,
limit: Int,
@@ -85,6 +91,7 @@ class LatestNextUpService
maxDays: Int,
useSeriesForPrimary: Boolean = true,
): List {
+ val removedSeries = getRemovedFromNextUp(userId)
val nextUpDateCutoff =
maxDays.takeIf { it > 0 }?.let { LocalDateTime.now().minusDays(it.toLong()) }
val request =
@@ -105,68 +112,31 @@ class LatestNextUpService
.content
.items
.map { BaseItem.from(it, api, useSeriesForPrimary) }
+ .filter {
+ val seriesId = it.data.seriesId
+ if (seriesId != null && seriesId in removedSeries) {
+ // User has previously removed the series
+ val lastPlayedDate = it.data.userData?.lastPlayedDate
+ if (lastPlayedDate != null) {
+ // If item played it after it was removed, should include it
+ lastPlayedDate > removedSeries[seriesId]
+ } else {
+ // If unknown last played, filter out
+ false
+ }
+ } else {
+ true
+ }
+ }
+
return nextUp
}
- suspend fun getLatest(
- user: UserDto,
- limit: Int,
- includedIds: List,
- ): List {
- 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): List {
- val rows =
- latestData.mapNotNull { (title, request) ->
- try {
- val latest =
- api.userLibraryApi
- .getLatestMedia(request)
- .content
- .map { BaseItem.from(it, api, true) }
- if (latest.isNotEmpty()) {
- HomeRowLoadingState.Success(
- title = title,
- items = latest,
- )
- } else {
- null
- }
- } catch (ex: Exception) {
- Timber.e(ex, "Exception fetching %s", title)
- HomeRowLoadingState.Error(
- title = title,
- exception = ex,
- )
- }
- }
- return rows
- }
-
+ /**
+ * Create the combined Continue Watching & Next Up items
+ *
+ * @see [DatePlayedService]
+ */
suspend fun buildCombined(
resume: List,
nextUp: List,
@@ -199,18 +169,112 @@ class LatestNextUpService
Timber.v("buildCombined took %s", duration)
return@withContext result
}
+
+ /**
+ * Remove a series from next up
+ */
+ suspend fun removeFromNextUp(
+ userId: UUID,
+ episode: BaseItem,
+ ) {
+ favoriteWatchManager.setWatched(episode.id, false)
+ episode.data.seriesId?.let { seriesId ->
+ displayPreferencesService.updateDisplayPreferences(userId) {
+ val removedIds =
+ get(REMOVED_KEY)
+ ?.let {
+ Json.decodeFromString(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 =
+ displayPreferencesService
+ .getDisplayPreferences(userId)
+ .customPrefs[REMOVED_KEY]
+ ?.let {
+ Json.decodeFromString(it).value
+ }.orEmpty()
+
+ suspend fun allowSeriesRemovedFromNextUp(
+ userId: UUID,
+ seriesId: UUID,
+ ) {
+ displayPreferencesService.updateDisplayPreferences(userId) {
+ val ids =
+ get(REMOVED_KEY)
+ ?.let {
+ Json.decodeFromString(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 =
- setOf(
- CollectionType.MOVIES,
- CollectionType.TVSHOWS,
- CollectionType.HOMEVIDEOS,
- // Exclude Live TV because a recording folder view will be used instead
- null, // Recordings & mixed collection types
- )
-
-data class LatestData(
- val title: String,
- val request: GetLatestMediaRequest,
+@Serializable
+data class RemovedSeriesIds(
+ val value: Map,
)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt
new file mode 100644
index 00000000..328346f3
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt
@@ -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(
+ 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(),
+ )
+ }
+ }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt
index a13881a9..beab6e64 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt
@@ -19,6 +19,9 @@ import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
+/**
+ * Service to manage media such as deletions
+ */
@Singleton
class MediaManagementService
@Inject
@@ -45,6 +48,9 @@ class MediaManagementService
return canDelete(item, appPreferences)
}
+ /**
+ * Check if the item can be deleted. This means the app setting is enabled and the user has permission.
+ */
fun canDelete(
item: BaseItem,
appPreferences: AppPreferences,
@@ -62,10 +68,14 @@ class MediaManagementService
}
}
+ /**
+ * Delete the item.
+ *
+ * This item will be sent through [deletedItemFlow] for other services or view models to react.
+ */
suspend fun deleteItem(item: BaseItem): DeleteResult {
try {
Timber.i("Deleting %s", item.id)
- // TODO enable
api.libraryApi.deleteItem(item.id)
_deletedItemFlow.emit(DeletedItem(item))
return DeleteResult.Success
@@ -88,6 +98,9 @@ sealed interface DeleteResult {
) : DeleteResult
}
+/**
+ * Convenience function to delete an item and show a Toast based on success or error
+ */
fun ViewModel.deleteItem(
context: Context,
mediaManagementService: MediaManagementService,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt
index 14a93ba2..d1a1543a 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt
@@ -21,6 +21,9 @@ import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
+/**
+ * Send media info to the server
+ */
@Singleton
class MediaReportService
@Inject
@@ -39,6 +42,9 @@ class MediaReportService
encodeDefaults = false
}
+ /**
+ * Fetch the media info and send it to the server
+ */
fun sendReportFor(itemId: UUID) {
ioScope.launchIO(ExceptionHandler(autoToast = true)) {
val item = api.userLibraryApi.getItem(itemId = itemId).content
@@ -46,6 +52,9 @@ class MediaReportService
}
}
+ /**
+ * Send the media report for the given item
+ */
suspend fun sendReportFor(item: BaseItemDto) {
val sources =
item.mediaSources ?: api.userLibraryApi
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt
new file mode 100644
index 00000000..6eddb51c
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt
@@ -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 = _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,
+ 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,
+ 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,
+ 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.. 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()
+ .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,
+) {
+ 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,
+) : 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.. =
+ remember(queueVersion, queueSize) {
+ object : AbstractList() {
+ override val size: Int
+ get() = player.mediaItemCount
+
+ override fun get(index: Int): AudioItem = player.getMediaItemAt(index).localConfiguration?.tag as AudioItem
+ }
+ }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt
index b83f0bd0..68486430 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt
@@ -7,6 +7,7 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.NavPinType
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
+import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.main.settings.Library
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
@@ -16,9 +17,11 @@ import com.github.damontecres.wholphin.util.supportedCollectionTypes
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -33,7 +36,11 @@ import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
+import kotlin.time.Duration.Companion.hours
+/**
+ * Gets the items to show in the nav drawer
+ */
@Singleton
class NavDrawerService
@Inject
@@ -44,11 +51,13 @@ class NavDrawerService
private val serverRepository: ServerRepository,
private val serverPreferencesDao: ServerPreferencesDao,
private val seerrServerRepository: SeerrServerRepository,
+ private val musicService: MusicService,
) {
private val _state = MutableStateFlow(NavDrawerItemState.EMPTY)
val state: StateFlow = _state
init {
+ // Handle updating the nav drawer when the user changes
serverRepository.currentUser
.asFlow()
.combine(serverRepository.currentUserDto.asFlow()) { user, userDto ->
@@ -69,12 +78,52 @@ class NavDrawerService
showToast(context, "Error fetching user's views")
}.launchIn(coroutineScope)
+ // Handle when the user has logged into a Seerr server
seerrServerRepository.active
.onEach { discoverActive ->
_state.update { it.copy(discoverEnabled = discoverActive) }
}.launchIn(coroutineScope)
+
+ // Handle when music is actively playing or not
+ coroutineScope.launchDefault {
+ musicService.state.collectLatest { music ->
+ Timber.v("MusicService updated")
+ when (music.status) {
+ NowPlayingStatus.PLAYING -> {
+ _state.update {
+ it.copy(
+ nowPlayingEnabled = true,
+ nowPlayingTitle = music.currentItemTitle,
+ )
+ }
+ }
+
+ NowPlayingStatus.IDLE -> {
+ _state.update {
+ it.copy(
+ nowPlayingEnabled = false,
+ nowPlayingTitle = null,
+ )
+ }
+ }
+
+ NowPlayingStatus.PAUSED -> {
+ delay(2.hours)
+ _state.update {
+ it.copy(
+ nowPlayingEnabled = false,
+ nowPlayingTitle = null,
+ )
+ }
+ }
+ }
+ }
+ }
}
+ /**
+ * Get all the libraries the user has access to
+ */
suspend fun getAllUserLibraries(
userId: UUID,
tvAccess: Boolean,
@@ -108,6 +157,9 @@ class NavDrawerService
return libraries
}
+ /**
+ * Get the libraries that the user has not "pinned". These will show in the More section.
+ */
suspend fun getFilteredUserLibraries(
user: JellyfinUser,
tvAccess: Boolean,
@@ -122,6 +174,9 @@ class NavDrawerService
return libraries
}
+ /**
+ * Update the current state of the nav drawer items
+ */
suspend fun updateNavDrawer(
user: JellyfinUser,
userDto: UserDto,
@@ -185,9 +240,11 @@ data class NavDrawerItemState(
val items: List,
val moreItems: List,
val discoverEnabled: Boolean,
+ val nowPlayingEnabled: Boolean,
+ val nowPlayingTitle: String?,
) {
companion object {
- val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false)
+ val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false, false, null)
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt
index a1754298..3208f4ec 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt
@@ -1,6 +1,6 @@
package com.github.damontecres.wholphin.services
-import androidx.navigation3.runtime.NavKey
+import androidx.navigation3.runtime.NavBackStack
import com.github.damontecres.wholphin.ui.nav.Destination
import org.acra.ACRA
import timber.log.Timber
@@ -14,7 +14,7 @@ import javax.inject.Singleton
class NavigationManager
@Inject
constructor() {
- var backStack: MutableList = mutableListOf()
+ var backStack: MutableList = NavBackStack(Destination.Home())
/**
* Go to the specified [com.github.damontecres.wholphin.ui.nav.Destination]
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PeopleFavorites.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PeopleFavorites.kt
index 6a35dda5..1df73d18 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/PeopleFavorites.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/PeopleFavorites.kt
@@ -10,6 +10,9 @@ import org.jellyfin.sdk.api.client.extensions.itemsApi
import javax.inject.Inject
import javax.inject.Singleton
+/**
+ * Gets people in media, specifically to check if they are favorited or not
+ */
@Singleton
class PeopleFavorites
@Inject
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt
index 4be082a5..66fb851c 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt
@@ -2,7 +2,6 @@ package com.github.damontecres.wholphin.services
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
-import com.github.damontecres.wholphin.ui.nav.Destination
import dagger.hilt.android.scopes.ActivityRetainedScoped
import javax.inject.Inject
@@ -20,13 +19,14 @@ class PlaybackLifecycleObserver
private var wasPlaying: Boolean? = null
override fun onStart(owner: LifecycleOwner) {
- val lastDest = navigationManager.backStack.lastOrNull()
- if (lastDest is Destination.Playback ||
- lastDest is Destination.PlaybackList ||
- lastDest is Destination.Slideshow
- ) {
- navigationManager.goBack()
- }
+ // TODO
+// val lastDest = navigationManager.backStack.lastOrNull()
+// if (lastDest is Destination.Playback ||
+// lastDest is Destination.PlaybackList ||
+// lastDest is Destination.Slideshow
+// ) {
+// navigationManager.goBack()
+// }
wasPlaying = null
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt
index 425ab94d..da00fd3d 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt
@@ -6,27 +6,39 @@ import android.content.Context
import android.os.Build
import android.os.Handler
import androidx.annotation.OptIn
-import androidx.datastore.core.DataStore
+import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.Player
+import androidx.media3.common.TrackSelectionParameters.AudioOffloadPreferences
+import androidx.media3.common.util.ExperimentalApi
import androidx.media3.common.util.UnstableApi
+import androidx.media3.datasource.DefaultDataSource
+import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.Renderer
+import androidx.media3.exoplayer.RenderersFactory
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
+import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
+import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.exoplayer.video.MediaCodecVideoRenderer
import androidx.media3.exoplayer.video.VideoRendererEventListener
-import com.github.damontecres.wholphin.preferences.AppPreference
-import com.github.damontecres.wholphin.preferences.AppPreferences
+import androidx.media3.extractor.DefaultExtractorsFactory
+import com.github.damontecres.wholphin.preferences.AssPlaybackMode
import com.github.damontecres.wholphin.preferences.MediaExtensionStatus
import com.github.damontecres.wholphin.preferences.PlaybackPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend
+import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.util.mpv.MpvPlayer
import dagger.hilt.android.qualifiers.ApplicationContext
+import io.github.peerless2012.ass.media.AssHandler
+import io.github.peerless2012.ass.media.factory.AssRenderersFactory
+import io.github.peerless2012.ass.media.kt.withAssMkvSupport
+import io.github.peerless2012.ass.media.parser.AssSubtitleParserFactory
+import io.github.peerless2012.ass.media.type.AssRenderType
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.firstOrNull
-import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
import timber.log.Timber
import java.lang.reflect.Constructor
import javax.inject.Inject
@@ -40,77 +52,23 @@ class PlayerFactory
@Inject
constructor(
@param:ApplicationContext private val context: Context,
- private val appPreferences: DataStore,
+ @param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient,
) {
@Volatile
var currentPlayer: Player? = null
private set
- fun createVideoPlayer(): Player {
- if (currentPlayer?.isReleased == false) {
- Timber.w("Player was not released before trying to create a new one!")
- currentPlayer?.release()
- }
-
- val prefs = runBlocking { appPreferences.data.firstOrNull()?.playbackPreferences }
- val backend = prefs?.playerBackend ?: AppPreference.PlayerBackendPref.defaultValue
- val newPlayer =
- when (backend) {
- PlayerBackend.PREFER_MPV,
- PlayerBackend.MPV,
- -> {
- val enableHardwareDecoding =
- prefs?.mpvOptions?.enableHardwareDecoding
- ?: AppPreference.MpvHardwareDecoding.defaultValue
- val useGpuNext =
- prefs?.mpvOptions?.useGpuNext
- ?: AppPreference.MpvGpuNext.defaultValue
- MpvPlayer(context, enableHardwareDecoding, useGpuNext)
- .apply {
- playWhenReady = true
- }
- }
-
- PlayerBackend.EXO_PLAYER,
- PlayerBackend.UNRECOGNIZED,
- -> {
- val extensions = prefs?.overrides?.mediaExtensionsEnabled
- val decodeAv1 = prefs?.overrides?.decodeAv1 == true
- Timber.v("extensions=$extensions")
- val rendererMode =
- when (extensions) {
- MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
- MediaExtensionStatus.MES_PREFERRED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
- MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF
- else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
- }
- ExoPlayer
- .Builder(context)
- .setRenderersFactory(
- WholphinRenderersFactory(context, decodeAv1)
- .setEnableDecoderFallback(true)
- .setExtensionRendererMode(rendererMode),
- ).build()
- .apply {
- playWhenReady = true
- }
- }
- }
- currentPlayer = newPlayer
- return newPlayer
- }
-
suspend fun createVideoPlayer(
backend: PlayerBackend,
prefs: PlaybackPreferences,
- ): Player {
+ ): PlayerCreation {
withContext(Dispatchers.Main) {
if (currentPlayer?.isReleased == false) {
Timber.w("Player was not released before trying to create a new one!")
currentPlayer?.release()
}
}
-
+ var assHandler: AssHandler? = null
val newPlayer =
when (backend) {
PlayerBackend.PREFER_MPV,
@@ -125,8 +83,14 @@ class PlayerFactory
PlayerBackend.UNRECOGNIZED,
-> {
val extensions = prefs.overrides.mediaExtensionsEnabled
+ val useLibAss =
+ prefs.overrides.assPlaybackMode == AssPlaybackMode.ASS_LIBASS
val decodeAv1 = prefs.overrides.decodeAv1
- Timber.v("extensions=$extensions")
+ Timber.v(
+ "extensions=%s, assPlaybackMode=%s",
+ extensions,
+ prefs.overrides.assPlaybackMode,
+ )
val rendererMode =
when (extensions) {
MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
@@ -134,18 +98,113 @@ class PlayerFactory
MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF
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
.Builder(context)
- .setRenderersFactory(
- WholphinRenderersFactory(context, decodeAv1)
- .setEnableDecoderFallback(true)
- .setExtensionRendererMode(rendererMode),
- ).build()
+ .setMediaSourceFactory(mediaSourceFactory)
+ .setRenderersFactory(renderersFactory)
+ .setTrackSelector(trackSelector)
+ .build()
+ .apply {
+ assHandler?.init(this)
+ withContext(Dispatchers.Main) {
+ setAudioAttributes(
+ AudioAttributes
+ .Builder()
+ .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
+ .build(),
+ false,
+ )
+ }
+ }
+ }
+
+ PlayerBackend.EXTERNAL_PLAYER -> {
+ throw IllegalArgumentException("Cannot create a player for external playback")
}
}
currentPlayer = newPlayer
- return newPlayer
+ return PlayerCreation(newPlayer, assHandler)
}
+
+ fun createAudioPlayer(extensions: MediaExtensionStatus = MediaExtensionStatus.MES_FALLBACK): ExoPlayer {
+ val rendererMode =
+ when (extensions) {
+ MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
+ MediaExtensionStatus.MES_PREFERRED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
+ MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF
+ else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
+ }
+ val extractorsFactory = createExtractorsFactory()
+ val renderersFactory: RenderersFactory =
+ WholphinRenderersFactory(context, false)
+ .setEnableDecoderFallback(true)
+ .setExtensionRendererMode(rendererMode)
+ val mediaSourceFactory =
+ DefaultMediaSourceFactory(
+ OkHttpDataSource.Factory(authOkHttpClient),
+ extractorsFactory,
+ )
+ val trackSelector = createTrackSelector()
+ return ExoPlayer
+ .Builder(context)
+ .setMediaSourceFactory(mediaSourceFactory)
+ .setRenderersFactory(renderersFactory)
+ .setTrackSelector(trackSelector)
+ .build()
+ .also {
+ it.setAudioAttributes(
+ AudioAttributes
+ .Builder()
+ .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
+ .build(),
+ false,
+ )
+ }
+ }
+
+ private fun createExtractorsFactory() =
+ DefaultExtractorsFactory()
+ .setConstantBitrateSeekingEnabled(true)
+ .setConstantBitrateSeekingAlwaysEnabled(true)
+
+ private fun createTrackSelector() =
+ DefaultTrackSelector(context).apply {
+ setParameters(
+ buildUponParameters()
+ .setAudioOffloadPreferences(
+ AudioOffloadPreferences
+ .Builder()
+ .setAudioOffloadMode(AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED)
+ .build(),
+ ),
+ )
+ }
}
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
class WholphinRenderersFactory(
context: Context,
private val av1Enabled: Boolean,
) : DefaultRenderersFactory(context) {
+ @OptIn(ExperimentalApi::class)
override fun buildVideoRenderers(
context: Context,
extensionRendererMode: Int,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt
index 4231d9b9..fbbc9b70 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt
@@ -38,6 +38,11 @@ import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
+/**
+ * Create [Playlist]s (not Jellyfin server playlist) for playback
+ *
+ * Used to create a queue of episodes or from Play All button
+ */
@Singleton
class PlaylistCreator
@Inject
@@ -74,6 +79,9 @@ class PlaylistCreator
return Playlist(episodes, startIndex)
}
+ /**
+ * Create from a server playlist ID
+ */
suspend fun createFromPlaylistId(
playlistId: UUID,
startIndex: Int?,
@@ -114,7 +122,12 @@ class PlaylistCreator
req =
GetItemsRequest(
parentId = item.id,
- enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
+ enableImageTypes =
+ listOf(
+ ImageType.PRIMARY,
+ ImageType.THUMB,
+ ImageType.LOGO,
+ ),
includeItemTypes = includeItemTypes,
recursive = true,
excludeItemIds = listOf(item.id),
@@ -133,6 +146,11 @@ class PlaylistCreator
return Playlist(items, 0)
}
+ /**
+ * Create a [Playlist] contextually based on the given item.
+ *
+ * For example, an episode creates a queue of next up episodes in the series, or a movie creates one for its parts if needed
+ */
suspend fun createFrom(
item: BaseItemDto,
startIndex: Int = 0,
@@ -270,6 +288,9 @@ class PlaylistCreator
}
}
+ /**
+ * Get the playlists on the server for a given media type
+ */
suspend fun getServerPlaylists(
mediaType: MediaType?,
scope: CoroutineScope,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt
index 06d116a4..91df9518 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt
@@ -22,6 +22,9 @@ import javax.inject.Singleton
import kotlin.math.roundToInt
import kotlin.time.Duration.Companion.seconds
+/**
+ * Handles determining whether the refresh rate and/or resolution of the display need to changed when playing media
+ */
@Singleton
class RefreshRateService
@Inject
@@ -119,6 +122,9 @@ class RefreshRateService
MainActivity.instance.changeDisplayMode(0)
}
+ /**
+ * Listens for the display to change so we known the refresh rate or resolution is updated
+ */
private class Listener(
val displayId: Int,
) : DisplayManager.DisplayListener {
@@ -213,6 +219,9 @@ class RefreshRateService
}
}
+/**
+ * Wrapper for a [Display.Mode]
+ */
data class DisplayMode(
val modeId: Int,
val physicalWidth: Int,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt
index c81893b3..6252a2a7 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt
@@ -38,6 +38,9 @@ import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds
+/**
+ * Handles the queue of items to show on the screensaver, both in-app or OS
+ */
@Singleton
class ScreensaverService
@Inject
@@ -67,6 +70,9 @@ class ScreensaverService
}.launchIn(scope)
}
+ /**
+ * Reset the timer before showing the in-app screensaver
+ */
fun pulse() {
waitJob?.cancel()
if (_state.value.enabled) {
@@ -95,6 +101,9 @@ class ScreensaverService
}
}
+ /**
+ * Immediately start the in-app screensaver
+ */
fun start() {
_state.update {
it.copy(
@@ -104,6 +113,9 @@ class ScreensaverService
}
}
+ /**
+ * Immediately stop the in-app screensaver
+ */
fun stop(cancelJob: Boolean) {
_state.update {
it.copy(
@@ -114,6 +126,9 @@ class ScreensaverService
if (cancelJob) waitJob?.cancel()
}
+ /**
+ * Signal to the OS for keeping the screen on such as during playback or when the in-app screensaver is active
+ */
fun keepScreenOn(keep: Boolean) {
scope.launchDefault {
val screensaverEnabled = _state.value.enabled
@@ -136,6 +151,9 @@ class ScreensaverService
keepScreenOn.update { keep }
}
+ /**
+ * Create a flow of items to show on the screensaver
+ */
fun createItemFlow(scope: CoroutineScope): Flow =
flow {
val prefs =
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrApi.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrApi.kt
index 5c62655f..685b858c 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrApi.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrApi.kt
@@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.services
+import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.setup.seerr.createSeerrApiUrl
@@ -19,7 +20,7 @@ class SeerrApi(
)
private set
- val active: Boolean get() = api.baseUrl.isNotNullOrBlank()
+ val active: Boolean get() = api.baseUrl.isNotNullOrBlank() && BuildConfig.DISCOVER_ENABLED
fun update(
baseUrl: String,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt
index 0f073af6..7096b833 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt
@@ -4,6 +4,7 @@ import android.content.Context
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.asFlow
import androidx.lifecycle.lifecycleScope
+import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
import com.github.damontecres.wholphin.api.seerr.model.AuthJellyfinPostRequest
import com.github.damontecres.wholphin.api.seerr.model.AuthLocalPostRequest
@@ -292,44 +293,46 @@ class UserSwitchListener
launchIO {
homeSettingsService.loadCurrentSettings(user.id)
}
- // Check for seerr server
- launchIO {
- seerrServerDao
- .getUsersByJellyfinUser(user.rowId)
- .lastOrNull()
- ?.let { seerrUser ->
- val server =
- seerrServerDao.getServer(seerrUser.serverId)?.server
- if (server != null) {
- Timber.i("Found a seerr user & server")
- try {
- seerrApi.update(server.url, seerrUser.credential)
- val userConfig =
- if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
- login(
- seerrApi.api,
- seerrUser.authMethod,
- seerrUser.username,
- seerrUser.password,
- )
- } else {
- seerrApi.api.usersApi.authMeGet()
- }
- seerrServerRepository.set(
- server,
- seerrUser,
- userConfig,
- )
- } catch (ex: Exception) {
- Timber.w(
- ex,
- "Error logging into %s",
- server.url,
- )
- seerrServerRepository.error(server, seerrUser, ex)
+ if (BuildConfig.DISCOVER_ENABLED) {
+ // Check for seerr server
+ launchIO {
+ seerrServerDao
+ .getUsersByJellyfinUser(user.rowId)
+ .lastOrNull()
+ ?.let { seerrUser ->
+ val server =
+ seerrServerDao.getServer(seerrUser.serverId)?.server
+ if (server != null) {
+ Timber.i("Found a seerr user & server")
+ try {
+ seerrApi.update(server.url, seerrUser.credential)
+ val userConfig =
+ if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
+ login(
+ seerrApi.api,
+ seerrUser.authMethod,
+ seerrUser.username,
+ seerrUser.password,
+ )
+ } else {
+ seerrApi.api.usersApi.authMeGet()
+ }
+ seerrServerRepository.set(
+ server,
+ seerrUser,
+ userConfig,
+ )
+ } catch (ex: Exception) {
+ Timber.w(
+ ex,
+ "Error logging into %s",
+ server.url,
+ )
+ seerrServerRepository.error(server, seerrUser, ex)
+ }
}
}
- }
+ }
}
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt
index 0c1801ac..d9a340c9 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt
@@ -191,6 +191,18 @@ class SeerrService
return "${base}${prefix}$path"
}
+ suspend fun createDiscoverItem(item: Any): DiscoverItem =
+ when (item) {
+ is MovieResult -> createDiscoverItem(item)
+ is MovieDetails -> createDiscoverItem(item)
+ is TvResult -> createDiscoverItem(item)
+ is TvDetails -> createDiscoverItem(item)
+ is SeerrSearchResult -> createDiscoverItem(item)
+ is CreditCast -> createDiscoverItem(item)
+ is CreditCrew -> createDiscoverItem(item)
+ else -> throw IllegalArgumentException("Unsupported type ${item::class.qualifiedName}")
+ }
+
suspend fun createDiscoverItem(movie: MovieResult): DiscoverItem =
DiscoverItem(
id = movie.id,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ServerEventListener.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ServerEventListener.kt
index f71c902a..979c90bd 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/ServerEventListener.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/ServerEventListener.kt
@@ -25,6 +25,9 @@ import org.jellyfin.sdk.model.api.MediaType
import timber.log.Timber
import javax.inject.Inject
+/**
+ * Listens for basic messages from the server such as messages
+ */
@ActivityScoped
class ServerEventListener
@Inject
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt
index e4910031..fa42469e 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt
@@ -16,7 +16,9 @@ import javax.inject.Singleton
@Singleton
class SetupNavigationManager
@Inject
- constructor() {
+ constructor(
+ private val navigationManager: NavigationManager,
+ ) {
var backStack: MutableList = mutableStateListOf(SetupDestination.Loading)
/**
@@ -25,6 +27,9 @@ class SetupNavigationManager
fun navigateTo(destination: SetupDestination) {
backStack[0] = destination
log()
+ if (destination !is SetupDestination.AppContent) {
+ navigationManager.reloadHome()
+ }
}
private fun log() {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt
index 6de45f50..05eac34e 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt
@@ -20,6 +20,9 @@ import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
+/**
+ * Manage the track choices for media
+ */
@Singleton
class StreamChoiceService
@Inject
@@ -94,6 +97,9 @@ class StreamChoiceService
result
}
+ /**
+ * Returns the audio stream that should play
+ */
suspend fun chooseAudioStream(
source: MediaSourceInfo,
seriesId: UUID?,
@@ -108,6 +114,9 @@ class StreamChoiceService
}
}
+ /**
+ * Returns the audio stream that should play
+ */
fun chooseAudioStream(
candidates: List,
itemPlayback: ItemPlayback?,
@@ -135,6 +144,9 @@ class StreamChoiceService
}
}
+ /**
+ * Returns the subtitle stream that should play
+ */
suspend fun chooseSubtitleStream(
source: MediaSourceInfo,
audioStream: MediaStream?,
@@ -196,6 +208,9 @@ class StreamChoiceService
)?.index
}
+ /**
+ * Returns the subtitle stream that should play
+ */
fun chooseSubtitleStream(
audioStreamLang: String?,
candidates: List,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt
index 3f676cba..6be37807 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt
@@ -15,6 +15,9 @@ import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import javax.inject.Inject
import javax.inject.Singleton
+/**
+ * Gets trailers for media
+ */
@Singleton
class TrailerService
@Inject
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt
index 709549cf..f2a0bd23 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt
@@ -45,6 +45,9 @@ import javax.inject.Singleton
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
+/**
+ * Checks if an app update is available
+ */
@Singleton
class UpdateChecker
@Inject
@@ -62,9 +65,14 @@ class UpdateChecker
private val NOTE_REGEX = Regex("")
- val ACTIVE = true
+ val ACTIVE = BuildConfig.UPDATING_ENABLED
}
+ /**
+ * If the app hasn't recently checked, check for any updates and if there is one, show a toast message
+ *
+ * This is safe to call many times because it will only show the toast at most once every 12 hours
+ */
suspend fun maybeShowUpdateToast(
updateUrl: String,
showNegativeToast: Boolean = false,
@@ -112,55 +120,81 @@ class UpdateChecker
}
}
+ /**
+ * Get the currently installed version
+ */
fun getInstalledVersion(): Version {
val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0)
- return Version.Companion.fromString(pkgInfo.versionName!!)
+ return Version.fromString(pkgInfo.versionName!!)
}
- suspend fun getLatestRelease(updateUrl: String): Release? {
+ suspend fun getRelease(version: Version): Release? {
+ val url =
+ "https://api.github.com/repos/damontecres/Wholphin/releases/tags/v${version.major}.${version.minor}.${version.patch}"
return withContext(Dispatchers.IO) {
+ val request =
+ Request
+ .Builder()
+ .url(url)
+ .get()
+ .build()
+ getRelease(request)
+ }
+ }
+
+ /**
+ * Get the latest released version
+ */
+ suspend fun getLatestRelease(updateUrl: String): Release? =
+ withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url(updateUrl)
.get()
.build()
- okHttpClient.newCall(request).execute().use {
- if (it.isSuccessful && it.body != null) {
- val result = Json.parseToJsonElement(it.body!!.string())
- val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull
- val version = Version.tryFromString(name)
- val publishedAt =
- result.jsonObject["published_at"]?.jsonPrimitive?.contentOrNull
- val body = result.jsonObject["body"]?.jsonPrimitive?.contentOrNull
- val downloadUrl =
- result.jsonObject["assets"]
- ?.jsonArray
- ?.let { assets -> getDownloadUrl(assets, BuildConfig.DEBUG) }
- Timber.v("version=$version, downloadUrl=$downloadUrl")
- if (version != null) {
- val notes =
- if (body.isNotNullOrBlank()) {
- NOTE_REGEX
- .findAll(body)
- .map { m ->
- m.groupValues[1]
- }.toList()
- } else {
- listOf()
- }
- return@use Release(version, downloadUrl, publishedAt, body, notes)
- } else {
- Timber.w("Update version parsing failed. name=$name")
- }
+ getRelease(request)
+ }
+
+ private fun getRelease(request: Request): Release? {
+ return okHttpClient.newCall(request).execute().use {
+ if (it.isSuccessful && it.body != null) {
+ val result = Json.parseToJsonElement(it.body!!.string())
+ val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull
+ val version = Version.tryFromString(name)
+ val publishedAt =
+ result.jsonObject["published_at"]?.jsonPrimitive?.contentOrNull
+ val body = result.jsonObject["body"]?.jsonPrimitive?.contentOrNull
+ val downloadUrl =
+ result.jsonObject["assets"]
+ ?.jsonArray
+ ?.let { assets -> getDownloadUrl(assets, BuildConfig.DEBUG) }
+ Timber.v("version=$version, downloadUrl=$downloadUrl")
+ if (version != null) {
+ val notes =
+ if (body.isNotNullOrBlank()) {
+ NOTE_REGEX
+ .findAll(body)
+ .map { m ->
+ m.groupValues[1]
+ }.toList()
+ } else {
+ emptyList()
+ }
+ return@use Release(version, downloadUrl, publishedAt, body, notes)
} 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(
release: Release,
callback: DownloadCallback,
@@ -263,6 +297,9 @@ class UpdateChecker
return targetFile
}
+ /**
+ * Check if the app has permission to write the download
+ */
fun hasPermissions(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
(
@@ -327,7 +364,19 @@ data class Release(
val publishedAt: String?,
val body: String?,
val notes: List,
-)
+) {
+ val content =
+ "# ${version}\n" +
+ (
+ (notes.joinToString("\n").takeIf { it.isNotNullOrBlank() } ?: "") +
+ (body ?: "")
+ ).replace(
+ Regex("https://github.com/\\w*/\\w+/pull/(\\d+)"),
+ "#$1",
+ )
+ // Remove the last line for full changelog since its just a link
+ .replace(Regex("\\*\\*Full Changelog\\*\\*.*"), "")
+}
interface DownloadCallback {
fun contentLength(contentLength: Long)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt
index 67e940b5..7c7fce11 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt
@@ -9,6 +9,9 @@ import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
+/**
+ * Get the current user's [UserPreferences]
+ */
@Singleton
class UserPreferencesService
@Inject
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt
index a44ab65b..38071cac 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt
@@ -34,26 +34,48 @@ import org.jellyfin.sdk.model.DeviceInfo
import javax.inject.Qualifier
import javax.inject.Singleton
+/**
+ * An [OkHttpClient] that includes the user's access token when making requests
+ */
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class AuthOkHttpClient
+/**
+ * A basic [OkHttpClient] that does not include auth
+ */
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class StandardOkHttpClient
+/**
+ * A [CoroutineScope] with [Dispatchers.IO]
+ */
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoCoroutineScope
+/**
+ * A [CoroutineScope] with [Dispatchers.Default]
+ */
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class DefaultCoroutineScope
+/**
+ * [Dispatchers.IO]
+ *
+ * @see IoCoroutineScope
+ */
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher
+/**
+ * [Dispatchers.Default]
+ *
+ * @see DefaultCoroutineScope
+ */
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class DefaultDispatcher
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt
index 31c92774..62609628 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt
@@ -5,6 +5,9 @@ import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.dataStoreFile
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.room.Room
import com.github.damontecres.wholphin.data.AppDatabase
import com.github.damontecres.wholphin.data.ItemPlaybackDao
@@ -85,4 +88,13 @@ object DatabaseModule {
produceNewData = { AppPreferences.getDefaultInstance() },
),
)
+
+ @Provides
+ @Singleton
+ fun keyValueDataStore(
+ @ApplicationContext context: Context,
+ ): DataStore =
+ PreferenceDataStoreFactory.create(
+ produceFile = { context.preferencesDataStoreFile("key_value") },
+ )
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt
index 297cbd64..fb40c008 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt
@@ -24,6 +24,9 @@ import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaDuration
+/**
+ * Schedules the [TvProviderWorker] to update the OS resume watching row
+ */
@ActivityScoped
class TvProviderSchedulerService
@Inject
@@ -44,6 +47,7 @@ class TvProviderSchedulerService
workManager.cancelUniqueWork(TvProviderWorker.WORK_NAME)
if (supportsTvProvider) {
if (user != null) {
+ // Schedule a new worker whenever the user changes
activity.lifecycleScope.launchIO(ExceptionHandler()) {
Timber.i("Scheduling TvProviderWorker for ${user.user}")
workManager
@@ -70,6 +74,9 @@ class TvProviderSchedulerService
}
}
+ /**
+ * Run the [TvProviderWorker] as a one-off instead of scheduled
+ */
fun launchOneTimeRefresh() {
if (supportsTvProvider) {
activity.lifecycleScope.launchIO(ExceptionHandler()) {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt
index 03ea0478..5a21d0d2 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt
@@ -43,6 +43,11 @@ import java.util.Date
import java.util.UUID
import kotlin.time.Duration.Companion.minutes
+/**
+ * Updates the Android OS resume watching row for a given Jellyfin user
+ *
+ * @see TvProviderSchedulerService
+ */
@HiltWorker
class TvProviderWorker
@AssistedInject
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/CoilTrickplayTransformation.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/CoilTrickplayTransformation.kt
index ace495bf..dc91eb3b 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/CoilTrickplayTransformation.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/CoilTrickplayTransformation.kt
@@ -18,7 +18,7 @@ class CoilTrickplayTransformation(
val index: Int,
) : Transformation() {
private val x: Int = imageIndex % numColumns
- private val y: Int = imageIndex / numRows
+ private val y: Int = imageIndex / numColumns
override val cacheKey: String
get() = "CoilTrickplayTransformation_$index,$x,$y"
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt
index 865c6d29..4d69fe0c 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt
@@ -38,6 +38,9 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.acra.ACRA
@@ -434,3 +437,18 @@ fun Response.toBaseItems(
fun Int?.gt(that: Int) = (this ?: 0) > that
fun Int?.lt(that: Int) = (this ?: 0) < that
+
+/**
+ * Simplifies endlessly collecting a flow
+ */
+fun Flow.collectLatestIn(
+ scope: CoroutineScope,
+ action: suspend (value: T) -> Unit,
+) {
+ scope.launchDefault { this@collectLatestIn.collectLatest(action) }
+}
+
+/**
+ * Easy way to combine two flows into a [Pair]
+ */
+fun Flow.combinePair(flow: Flow): Flow> = combine(flow) { t1, t2 -> Pair(t1, t2) }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt
index 8f4066bb..7b64b1a4 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt
@@ -7,6 +7,7 @@ import androidx.compose.ui.text.buildAnnotatedString
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication
import org.jellyfin.sdk.model.api.BaseItemDto
+import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaSegmentType
import timber.log.Timber
import java.time.LocalDate
@@ -16,8 +17,25 @@ import java.time.format.DateTimeParseException
import java.time.format.FormatStyle
import java.util.Locale
-val TimeFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
-val DateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("MMM d, yyyy")
+private var timeFormatter: DateTimeFormatter =
+ DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(Locale.getDefault())
+
+fun getTimeFormatter(): DateTimeFormatter {
+ if (timeFormatter.locale != Locale.getDefault()) {
+ timeFormatter = timeFormatter.withLocale(Locale.getDefault())
+ }
+ return timeFormatter
+}
+
+private var dateFormatter: DateTimeFormatter =
+ DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(Locale.getDefault())
+
+fun getDateFormatter(): DateTimeFormatter {
+ if (dateFormatter.locale != Locale.getDefault()) {
+ dateFormatter = dateFormatter.withLocale(Locale.getDefault())
+ }
+ return dateFormatter
+}
// TODO server returns in UTC, but sdk converts to local time
// eg 2020-02-14T00:00:00.0000000Z => 2020-02-13T17:00:00 PT => Feb 13, 2020
@@ -25,9 +43,11 @@ val DateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("MMM d, yyyy"
/**
* Format a [LocalDateTime] as `Aug 24, 2000`
*/
-fun formatDateTime(dateTime: LocalDateTime): String = DateFormatter.format(dateTime)
+fun formatDateTime(dateTime: LocalDateTime): String = getDateFormatter().format(dateTime)
-fun formatDate(dateTime: LocalDate): String = DateFormatter.format(dateTime)
+fun formatDate(dateTime: LocalDate): String = getDateFormatter().format(dateTime)
+
+fun formatDate(dateTime: LocalDateTime): String = getDateFormatter().format(dateTime)
fun toLocalDate(date: String?): LocalDate? =
date?.let {
@@ -109,30 +129,25 @@ fun abbreviateNumber(number: Int): String {
return String.format(Locale.getDefault(), "%.1f%s", count, abbrevSuffixes[unit])
}
-val byteSuffixes = listOf("B", "KB", "MB", "GB", "TB")
+val byteSuffixes = listOf("B", "KiB", "MiB", "GiB", "TiB")
val byteRateSuffixes = listOf("bps", "kbps", "mbps", "gbps", "tbps")
-/**
- * Format bytes
- */
-fun formatBytes(
- bytes: Int,
- suffixes: List = byteSuffixes,
-) = formatBytes(bytes.toLong(), suffixes)
-
fun formatBytes(
bytes: Long,
suffixes: List = byteSuffixes,
+ divisor: Int = 1024,
): String {
var unit = 0
var count = bytes.toDouble()
- while (count >= 1024 && unit + 1 < suffixes.size) {
- count /= 1024
+ while (count >= divisor && unit + 1 < suffixes.size) {
+ count /= divisor
unit++
}
- return String.format(Locale.getDefault(), "%.2f%s", count, suffixes[unit])
+ return String.format(Locale.getDefault(), "%.2f %s", count, suffixes[unit])
}
+fun formatBitrate(bitrate: Int) = formatBytes(bitrate.toLong(), byteRateSuffixes, 1000)
+
@get:StringRes
val MediaSegmentType.stringRes: Int
get() =
@@ -184,3 +199,45 @@ fun listToDotString(
}
}
}
+
+@StringRes
+fun formatTypeName(type: BaseItemKind): Int =
+ when (type) {
+ BaseItemKind.MOVIE -> R.string.movies
+ BaseItemKind.SERIES -> R.string.tv_shows
+ BaseItemKind.EPISODE -> R.string.episodes
+ BaseItemKind.VIDEO -> R.string.videos
+ BaseItemKind.PLAYLIST -> R.string.playlists
+ BaseItemKind.PERSON -> R.string.people
+ BaseItemKind.BOX_SET -> R.string.collections
+ BaseItemKind.AUDIO -> TODO()
+ BaseItemKind.CHANNEL -> R.string.channels
+ BaseItemKind.GENRE -> R.string.genres
+ BaseItemKind.LIVE_TV_CHANNEL -> R.string.channels
+ BaseItemKind.MUSIC_ALBUM -> TODO()
+ BaseItemKind.MUSIC_ARTIST -> TODO()
+ BaseItemKind.MUSIC_GENRE -> TODO()
+ BaseItemKind.MUSIC_VIDEO -> TODO()
+ BaseItemKind.PHOTO -> R.string.photos
+ BaseItemKind.PHOTO_ALBUM -> TODO()
+ BaseItemKind.PROGRAM -> TODO()
+ BaseItemKind.RECORDING -> TODO()
+ BaseItemKind.SEASON -> R.string.tv_seasons
+ BaseItemKind.STUDIO -> R.string.studios
+ BaseItemKind.TRAILER -> R.string.trailers
+ BaseItemKind.TV_CHANNEL -> R.string.channels
+ BaseItemKind.TV_PROGRAM -> TODO()
+ BaseItemKind.USER_ROOT_FOLDER -> TODO()
+ BaseItemKind.USER_VIEW -> TODO()
+ BaseItemKind.YEAR -> TODO()
+ BaseItemKind.AGGREGATE_FOLDER -> TODO()
+ BaseItemKind.AUDIO_BOOK -> TODO()
+ BaseItemKind.BASE_PLUGIN_FOLDER -> TODO()
+ BaseItemKind.BOOK -> TODO()
+ BaseItemKind.CHANNEL_FOLDER_ITEM -> TODO()
+ BaseItemKind.COLLECTION_FOLDER -> TODO()
+ BaseItemKind.FOLDER -> TODO()
+ BaseItemKind.MANUAL_PLAYLISTS_FOLDER -> TODO()
+ BaseItemKind.LIVE_TV_PROGRAM -> TODO()
+ BaseItemKind.PLAYLISTS_FOLDER -> TODO()
+ }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt
index 51d003fe..8d00edfd 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt
@@ -19,6 +19,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -98,10 +99,15 @@ fun BannerCard(
}
}
var imageError by remember(imageUrl) { mutableStateOf(false) }
+
+ // Stabilize callbacks to prevent AsyncImage from recomposing
+ val currentOnClick by rememberUpdatedState(onClick)
+ val currentOnLongClick by rememberUpdatedState(onLongClick)
+
Card(
modifier = modifier.size(cardHeight * aspectRatio, cardHeight),
- onClick = onClick,
- onLongClick = onLongClick,
+ onClick = { currentOnClick() },
+ onLongClick = { currentOnLongClick() },
interactionSource = interactionSource,
colors =
CardDefaults.colors(
@@ -119,7 +125,7 @@ fun BannerCard(
model = imageUrl,
contentDescription = null,
contentScale = imageContentScale,
- onError = { imageError = true },
+ onError = remember { { imageError = true } },
modifier = Modifier.fillMaxSize(),
)
} else {
@@ -214,7 +220,7 @@ fun BannerCardWithTitle(
) {
val focused by interactionSource.collectIsFocusedAsState()
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
- val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
+ val spaceBelow by animateDpAsState(if (focused) 0.dp else 8.dp)
val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
val width = cardHeight * aspectRationToUse
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt
index a7df0cb3..93e9adcc 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt
@@ -14,9 +14,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -34,8 +35,10 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
+import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
+import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.SeerrAvailability
@@ -50,14 +53,14 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import kotlinx.coroutines.delay
@Composable
-@NonRestartableComposable
fun DiscoverItemCard(
item: DiscoverItem?,
onClick: () -> Unit,
onLongClick: () -> Unit,
- showOverlay: Boolean,
modifier: Modifier = Modifier,
+ showOverlay: Boolean = true,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+ width: Dp = Cards.height2x3 * AspectRatios.TALL,
) {
val focused by interactionSource.collectIsFocusedAsState()
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
@@ -77,16 +80,15 @@ fun DiscoverItemCard(
} else {
focusedAfterDelay = false
}
- val width = Cards.height2x3 * AspectRatios.TALL
- val height = Dp.Unspecified * (1f / AspectRatios.TALL)
Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(spaceBetween),
- modifier = modifier.size(width, height),
+ modifier = modifier.size(width, Dp.Unspecified),
) {
Card(
modifier =
Modifier
- .size(Dp.Unspecified, Cards.height2x3)
+ .size(width, Dp.Unspecified)
.aspectRatio(AspectRatios.TALL),
onClick = onClick,
onLongClick = onLongClick,
@@ -286,6 +288,88 @@ fun PartiallyAvailableIndicator(modifier: Modifier = Modifier) {
}
}
+@Composable
+fun DiscoverViewMoreCard(
+ onClick: () -> Unit,
+ onLongClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+) {
+ val focused by interactionSource.collectIsFocusedAsState()
+ val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
+ val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
+ var focusedAfterDelay by remember { mutableStateOf(false) }
+
+ val hideOverlayDelay = 500L
+ if (focused) {
+ LaunchedEffect(Unit) {
+ delay(hideOverlayDelay)
+ if (focused) {
+ focusedAfterDelay = true
+ } else {
+ focusedAfterDelay = false
+ }
+ }
+ } else {
+ focusedAfterDelay = false
+ }
+ val width = Cards.height2x3 * AspectRatios.TALL
+ val height = Dp.Unspecified * (1f / AspectRatios.TALL)
+ Column(
+ verticalArrangement = Arrangement.spacedBy(spaceBetween),
+ modifier = modifier.size(width, height),
+ ) {
+ Card(
+ modifier =
+ Modifier
+ .size(Dp.Unspecified, Cards.height2x3)
+ .aspectRatio(AspectRatios.TALL),
+ onClick = onClick,
+ onLongClick = onLongClick,
+ interactionSource = interactionSource,
+ colors =
+ CardDefaults.colors(
+ containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
+ ),
+ ) {
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize(),
+ ) {
+ Icon(
+ imageVector = Icons.Default.ArrowForward,
+ tint = MaterialTheme.colorScheme.onSurface,
+ contentDescription = "View more",
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+ }
+
+ Column(
+ verticalArrangement = Arrangement.spacedBy(0.dp),
+ modifier =
+ Modifier
+ .padding(bottom = spaceBelow)
+ .fillMaxWidth(),
+ ) {
+ Text(
+ text = stringResource(R.string.view_more),
+ maxLines = 1,
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontWeight = FontWeight.SemiBold,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 4.dp)
+ .enableMarquee(focusedAfterDelay),
+ )
+ }
+ }
+}
+
@PreviewTvSpec
@Composable
private fun Preview() {
@@ -294,6 +378,11 @@ private fun Preview() {
PendingIndicator()
AvailableIndicator()
PartiallyAvailableIndicator()
+ DiscoverViewMoreCard(
+ onClick = {},
+ onLongClick = {},
+ modifier = Modifier,
+ )
}
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt
index 279ca7b5..ab6263ae 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt
@@ -45,6 +45,7 @@ fun EpisodeCard(
modifier: Modifier = Modifier,
imageHeight: Dp = Dp.Unspecified,
imageWidth: Dp = Dp.Unspecified,
+ showImageOverlay: Boolean = false,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val dto = item?.data
@@ -94,7 +95,7 @@ fun EpisodeCard(
ItemCardImage(
item = item,
name = item?.name,
- showOverlay = false,
+ showOverlay = showImageOverlay,
favorite = dto?.userData?.isFavorite ?: false,
watched = dto?.userData?.played ?: false,
unwatchedCount = dto?.userData?.unplayedItemCount ?: -1,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt
index 314a7e1a..c9f6a142 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt
@@ -10,8 +10,10 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
@@ -45,6 +47,10 @@ fun ItemRow(
val firstFocus = remember { FocusRequester() }
val focusRequester = remember { FocusRequester() }
var position by rememberInt()
+
+ val currentOnClickItem by rememberUpdatedState(onClickItem)
+ val currentOnLongClickItem by rememberUpdatedState(onLongClickItem)
+
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
@@ -54,12 +60,8 @@ fun ItemRow(
}
},
) {
- Text(
- text = title,
- style = MaterialTheme.typography.titleLarge,
- color = MaterialTheme.colorScheme.onBackground,
- modifier = Modifier.padding(start = 8.dp),
- )
+ ItemRowTitle(title)
+
LazyRow(
state = state,
horizontalArrangement = Arrangement.spacedBy(horizontalPadding),
@@ -73,25 +75,50 @@ fun ItemRow(
) {
itemsIndexed(items) { index, item ->
val cardModifier =
- if (index == position) {
- Modifier.focusRequester(firstFocus)
- } else {
- Modifier
+ remember(index, position) {
+ if (index == position) {
+ Modifier.focusRequester(firstFocus)
+ } else {
+ Modifier
+ }
}
+
+ val onClick =
+ remember(index, item) {
+ {
+ position = index
+ if (item != null) currentOnClickItem(index, item)
+ }
+ }
+
+ val onLongClick =
+ remember(index, item) {
+ {
+ position = index
+ if (item != null) currentOnLongClickItem(index, item)
+ }
+ }
+
cardContent.invoke(
index,
item,
cardModifier,
- {
- position = index
- if (item != null) onClickItem.invoke(index, item)
- },
- {
- position = index
- if (item != null) onLongClickItem.invoke(index, item)
- },
+ onClick,
+ onLongClick,
)
}
}
}
}
+
+@Composable
+@NonRestartableComposable
+fun ItemRowTitle(
+ title: String,
+ modifier: Modifier = Modifier,
+) = Text(
+ text = title,
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onBackground,
+ modifier = modifier.padding(start = 8.dp),
+)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt
index 2ce33e09..4e146134 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
@@ -25,9 +26,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.layout.layout
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Border
@@ -80,7 +83,7 @@ fun PersonCard(
) {
val hideOverlayDelay = 1_000L
- val focused = interactionSource.collectIsFocusedAsState().value
+ val focused by interactionSource.collectIsFocusedAsState()
var focusedAfterDelay by remember { mutableStateOf(false) }
if (focused) {
@@ -95,10 +98,13 @@ fun PersonCard(
} else {
focusedAfterDelay = false
}
- val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
- val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
+
+ // Do not use `by` here, this way we are Defer reads and recompositions to only when modifier calculates
+ val spaceBetweenState = animateDpAsState(if (focused) 12.dp else 4.dp, label = "spaceBetween")
+ val spaceBelowState = animateDpAsState(if (focused) 4.dp else 12.dp, label = "spaceBelow")
+
Column(
- verticalArrangement = Arrangement.spacedBy(spaceBetween),
+ verticalArrangement = Arrangement.spacedBy(4.dp), // Fixed base spacing
modifier = modifier,
) {
Card(
@@ -168,8 +174,16 @@ fun PersonCard(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
- .padding(bottom = spaceBelow)
- .fillMaxWidth(),
+ // Optimization: move animation reads to layout/draw phase
+ .offset {
+ IntOffset(0, (spaceBetweenState.value - 4.dp).roundToPx())
+ }.layout { measurable, constraints ->
+ val paddingPx = spaceBelowState.value.roundToPx()
+ val placeable = measurable.measure(constraints)
+ layout(placeable.width, placeable.height + paddingPx) {
+ placeable.placeRelative(0, 0)
+ }
+ }.fillMaxWidth(),
) {
Text(
text = name ?: "",
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt
index b066d372..962ab727 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
@@ -18,10 +19,12 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
@@ -52,7 +55,7 @@ fun SeasonCard(
val imageUrlService = LocalImageUrlService.current
val density = LocalDensity.current
val imageUrl =
- remember(item, imageHeight, imageWidth) {
+ remember(item, imageHeight, imageWidth, density) {
if (item != null) {
val fillHeight =
if (imageHeight != Dp.Unspecified) {
@@ -125,14 +128,17 @@ fun SeasonCard(
aspectRatio: Float = AspectRatios.TALL,
) {
val focused by interactionSource.collectIsFocusedAsState()
- val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
- val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
+ // Do not use `by` here, this way we are Defer reads and recompositions to only when modifier calculates
+ val spaceBetween = animateDpAsState(if (focused) 12.dp else 4.dp, label = "spaceBetween")
+ val spaceBelow = animateDpAsState(if (focused) 4.dp else 12.dp, label = "spaceBelow")
+
val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
val width = imageHeight * aspectRationToUse
val height = imageWidth * (1f / aspectRationToUse)
+
Column(
- verticalArrangement = Arrangement.spacedBy(spaceBetween),
+ verticalArrangement = Arrangement.spacedBy(4.dp), // Fixed base spacing
modifier = modifier.size(width, height),
) {
Card(
@@ -173,8 +179,16 @@ fun SeasonCard(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
- .padding(bottom = spaceBelow)
- .fillMaxWidth(),
+ // Optimization: move animation reads to layout/draw phase
+ .offset {
+ IntOffset(0, (spaceBetween.value - 4.dp).roundToPx())
+ }.layout { measurable, constraints ->
+ val paddingPx = spaceBelow.value.roundToPx()
+ val placeable = measurable.measure(constraints)
+ layout(placeable.width, placeable.height + paddingPx) {
+ placeable.placeRelative(0, 0)
+ }
+ }.fillMaxWidth(),
) {
Text(
text = title ?: "",
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/StudioCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/StudioCard.kt
new file mode 100644
index 00000000..fb89a58b
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/StudioCard.kt
@@ -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),
+ )
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Button.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Button.kt
index c65069bd..ff85886d 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Button.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Button.kt
@@ -35,6 +35,11 @@ import androidx.tv.material3.ProvideTextStyle
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
+/**
+ * This is a re-implementation of [androidx.tv.material3.Button] with altered sizing, padding, colors, etc
+ *
+ * This allows for creating smaller and fully circular Buttons.
+ */
@Composable
fun Button(
onClick: () -> Unit,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt
index a4e4ef22..f574e883 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt
@@ -21,6 +21,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@@ -63,6 +64,7 @@ import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MediaReportService
+import com.github.damontecres.wholphin.services.MusicService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.UserPreferencesService
@@ -79,6 +81,7 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
+import com.github.damontecres.wholphin.ui.detail.music.addToQueue
import com.github.damontecres.wholphin.ui.equalsNotNull
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
@@ -138,6 +141,7 @@ class CollectionFolderViewModel
private val themeSongPlayer: ThemeSongPlayer,
private val userPreferencesService: UserPreferencesService,
private val mediaManagementService: MediaManagementService,
+ private val musicService: MusicService,
val mediaReportService: MediaReportService,
@Assisted itemId: String,
@Assisted initialSortAndDirection: SortAndDirection?,
@@ -398,6 +402,7 @@ class CollectionFolderViewModel
ImageType.PRIMARY,
ImageType.THUMB,
ImageType.BACKDROP,
+ ImageType.LOGO,
),
includeItemTypes = includeItemTypes,
recursive = recursive,
@@ -530,6 +535,11 @@ class CollectionFolderViewModel
item: BaseItem,
appPreferences: AppPreferences,
): Boolean = mediaManagementService.canDelete(item, appPreferences)
+
+ fun addToQueue(
+ item: BaseItem,
+ index: Int,
+ ) = addToQueue(api, musicService, item, index)
}
/**
@@ -774,6 +784,9 @@ fun CollectionFolderGrid(
onClickGoTo = {
onClickItem.invoke(position, it)
},
+ onClickAddToQueue = {
+ viewModel.addToQueue(it, 0)
+ },
),
),
onDismissRequest = { moreDialog.makeAbsent() },
@@ -881,15 +894,7 @@ fun CollectionFolderGridContent(
modifier = Modifier.fillMaxWidth(),
) {
if (showTitle) {
- Text(
- text = title,
- style = MaterialTheme.typography.displayMedium,
- color = MaterialTheme.colorScheme.onBackground,
- textAlign = TextAlign.Center,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.fillMaxWidth(),
- )
+ GridTitle(title)
}
val endPadding =
16.dp + if (sortAndDirection.sort == ItemSortBy.SORT_NAME) 24.dp else 0.dp
@@ -960,11 +965,12 @@ fun CollectionFolderGridContent(
AnimatedVisibility(viewOptions.showDetails) {
HomePageHeader(
item = focusedItem,
+ showLogo = preferences.appPreferences.interfacePreferences.showLogos,
modifier =
Modifier
.fillMaxWidth()
.height(200.dp)
- .padding(top = 48.dp, bottom = 32.dp, start = 8.dp),
+ .padding(HeaderUtils.padding),
)
}
when (val state = loadingState) {
@@ -1144,3 +1150,18 @@ val CollectionType.baseItemKinds: List
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(),
+)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt
index 82aab6ba..538f623b 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt
@@ -76,6 +76,8 @@ import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
+import org.jellyfin.sdk.model.serializer.toUUIDOrNull
+import java.util.UUID
/**
* Parameters for rendering a [DialogPopup]
@@ -90,6 +92,9 @@ sealed interface DialogItemEntry
data object DialogItemDivider : DialogItemEntry
+/**
+ * An item within a [DialogPopup]
+ */
data class DialogItem(
val headlineContent: @Composable () -> Unit,
val onClick: () -> Unit,
@@ -594,6 +599,7 @@ fun ConfirmDeleteDialog(
fun chooseVersionParams(
context: Context,
sources: List,
+ chosenSourceId: UUID?,
onClick: (Int) -> Unit,
): DialogParams =
DialogParams(
@@ -601,6 +607,7 @@ fun chooseVersionParams(
title = context.getString(R.string.choose_stream, context.getString(R.string.version)),
items =
sources.filter { it.id.isNotNullOrBlank() }.mapIndexed { index, source ->
+ val uuid = source.id?.toUUIDOrNull()
val videoStream =
source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }
val title = source.name ?: source.path ?: source.id ?: ""
@@ -608,6 +615,9 @@ fun chooseVersionParams(
headlineContent = {
Text(text = title)
},
+ leadingContent = {
+ SelectedLeadingContent(uuid != null && uuid == chosenSourceId)
+ },
supportingContent = {
videoStream?.displayTitle?.let { Text(text = it) }
},
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/EditTextBox.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/EditTextBox.kt
index a85d9fc9..47b4ebc9 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/EditTextBox.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/EditTextBox.kt
@@ -34,7 +34,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
@@ -49,8 +48,10 @@ import androidx.tv.material3.MaterialTheme
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
-import com.google.protobuf.value
+/**
+ * An input field for text customized for TV entry
+ */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EditTextBox(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ErrorMessage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ErrorMessage.kt
index 76c9e097..59bb456f 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ErrorMessage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ErrorMessage.kt
@@ -14,6 +14,7 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs
+import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -98,3 +99,13 @@ fun ErrorMessage(
exception = error.exception,
modifier = modifier,
)
+
+@Composable
+fun ErrorMessage(
+ error: DataLoadingState.Error,
+ modifier: Modifier = Modifier,
+) = ErrorMessage(
+ message = error.message,
+ exception = error.exception,
+ modifier = modifier,
+)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt
index a218b9c7..e881bb98 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt
@@ -46,6 +46,7 @@ import com.github.damontecres.wholphin.data.filter.GenreFilter
import com.github.damontecres.wholphin.data.filter.ItemFilterBy
import com.github.damontecres.wholphin.data.filter.OfficialRatingFilter
import com.github.damontecres.wholphin.data.filter.PlayedFilter
+import com.github.damontecres.wholphin.data.filter.StudioFilter
import com.github.damontecres.wholphin.data.filter.VideoTypeFilter
import com.github.damontecres.wholphin.data.filter.YearFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilter
@@ -55,6 +56,14 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus
import java.util.UUID
+/**
+ * Button for filtering data.
+ *
+ * Clicking on it will show a drop-down menu of filterable options. Many of these show a second drop-down menu when clicked.
+ *
+ * @see GetItemsFilter
+ * @see ItemFilterBy
+ */
@Composable
fun FilterByButton(
filterOptions: List>,
@@ -198,7 +207,9 @@ fun FilterByButton(
val isSelected =
remember(currentValue) {
when (filterOption) {
- GenreFilter -> {
+ GenreFilter,
+ StudioFilter,
+ -> {
(currentValue as? List)
.orEmpty()
.contains(value.value)
@@ -263,7 +274,9 @@ fun FilterByButton(
onClick = {
val newFilter =
when (filterOption) {
- GenreFilter -> {
+ GenreFilter,
+ StudioFilter,
+ -> {
val list = (currentValue as? List).orEmpty()
val newValue =
list
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt
index d42eea5c..21ac519d 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt
@@ -139,6 +139,7 @@ class GenreViewModel
nameLessThan = letter.toString(),
limit = 0,
enableTotalRecordCount = true,
+ includeItemTypes = includeItemTypes,
)
val result by GetGenresRequestHandler.execute(api, request)
return@withContext result.totalRecordCount
@@ -156,6 +157,9 @@ private val genreCache by lazy {
}
}
+/**
+ * Create a mapping from genre IDs to image URLs using random items within each genre
+ */
suspend fun getGenreImageMap(
api: ApiClient,
userId: UUID?,
@@ -230,6 +234,9 @@ data class Genre(
override val sortName: String get() = name
}
+/**
+ * Show an optimized grid of genres for a library
+ */
@Composable
fun GenreCardGrid(
itemId: UUID,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreText.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreText.kt
index 3598fb93..c697b667 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreText.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreText.kt
@@ -11,6 +11,11 @@ import androidx.tv.material3.Text
private const val MAX_TO_SHOW = 4
+/**
+ * Display a comma separated list of genres
+ *
+ * Only the first few will be shown
+ */
@Composable
fun GenreText(
genres: List,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/HeaderUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/HeaderUtils.kt
new file mode 100644
index 00000000..0ebdd1db
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/HeaderUtils.kt
@@ -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)
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt
index 38591a67..13ce42a7 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt
@@ -82,6 +82,9 @@ class ItemGridViewModel
}
}
+/**
+ * Display a grid of a list of arbitrary item IDs such as for [com.github.damontecres.wholphin.data.ExtrasItem]
+ */
@Composable
fun ItemGrid(
destination: Destination.ItemGrid,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/LicenseInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/LicenseInfo.kt
index 67c56f12..6a2300c2 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/LicenseInfo.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/LicenseInfo.kt
@@ -6,6 +6,9 @@ import androidx.compose.ui.Modifier
import com.mikepenz.aboutlibraries.ui.compose.android.produceLibraries
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
+/**
+ * Displays dependencies' license information to comply with attribution
+ */
@Composable
fun LicenseInfo(modifier: Modifier = Modifier) {
val libraries by produceLibraries()
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/OverviewText.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/OverviewText.kt
index 7417028f..874585de 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/OverviewText.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/OverviewText.kt
@@ -23,6 +23,9 @@ import androidx.tv.material3.Text
import com.github.damontecres.wholphin.ui.playOnClickSound
import com.github.damontecres.wholphin.ui.playSoundOnFocus
+/**
+ * Show the overview text for an item. Uses a fixed size and allows for clicking.
+ */
@Composable
fun OverviewText(
overview: String,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt
index cf5b0697..d4931d77 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt
@@ -21,8 +21,8 @@ import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
-import com.github.damontecres.wholphin.ui.TimeFormatter
import com.github.damontecres.wholphin.ui.dot
+import com.github.damontecres.wholphin.ui.getTimeFormatter
import com.github.damontecres.wholphin.ui.util.LocalClock
import kotlin.time.Duration
@@ -107,7 +107,7 @@ fun TimeRemaining(
val now by LocalClock.current.now
val remainingStr =
remember(remaining, now) {
- val endTimeStr = TimeFormatter.format(now.plusSeconds(remaining.inWholeSeconds))
+ val endTimeStr = getTimeFormatter().format(now.plusSeconds(remaining.inWholeSeconds))
buildAnnotatedString {
dot()
append(context.getString(R.string.ends_at, endTimeStr))
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt
index c3d1a4b6..1e2d3e61 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt
@@ -19,12 +19,14 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MediaReportService
+import com.github.damontecres.wholphin.services.MusicService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.deleteItem
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
@@ -34,6 +36,7 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
+import com.github.damontecres.wholphin.ui.detail.music.addToQueue
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.HomePageContent
@@ -42,18 +45,25 @@ import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState
+import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
+import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.MediaType
import java.util.UUID
+/**
+ * Abstract [ViewModel] for the "Recommended" tab for a library
+ */
abstract class RecommendedViewModel(
- val context: Context,
+ @param:ApplicationContext val context: Context,
+ val api: ApiClient,
val navigationManager: NavigationManager,
val favoriteWatchManager: FavoriteWatchManager,
val mediaReportService: MediaReportService,
+ private val musicService: MusicService,
private val backdropService: BackdropService,
private val mediaManagementService: MediaManagementService,
) : ViewModel() {
@@ -110,13 +120,14 @@ abstract class RecommendedViewModel(
fun update(
@StringRes title: Int,
+ viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
block: suspend () -> List,
): Deferred =
viewModelScope.async(Dispatchers.IO) {
val titleStr = context.getString(title)
val row =
try {
- HomeRowLoadingState.Success(titleStr, block.invoke())
+ HomeRowLoadingState.Success(titleStr, block.invoke(), viewOptions)
} catch (ex: Exception) {
HomeRowLoadingState.Error(titleStr, null, ex)
}
@@ -141,6 +152,11 @@ abstract class RecommendedViewModel(
item: BaseItem,
appPreferences: AppPreferences,
): Boolean = mediaManagementService.canDelete(item, appPreferences)
+
+ fun addToQueue(
+ item: BaseItem,
+ index: Int,
+ ) = addToQueue(api, musicService, item, index)
}
@Composable
@@ -205,6 +221,7 @@ fun RecommendedContent(
},
showClock = preferences.appPreferences.interfacePreferences.showClock,
onUpdateBackdrop = viewModel::updateBackdrop,
+ showLogo = preferences.appPreferences.interfacePreferences.showLogos,
modifier = modifier,
)
}
@@ -237,6 +254,9 @@ fun RecommendedContent(
},
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = { showDeleteDialog = RowColumnItem(position, item) },
+ onClickAddToQueue = {
+ viewModel.addToQueue(it, 0)
+ },
),
),
onDismissRequest = { moreDialog.makeAbsent() },
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt
index 392c77a5..3228046b 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt
@@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MediaReportService
+import com.github.damontecres.wholphin.services.MusicService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SuggestionService
import com.github.damontecres.wholphin.services.SuggestionsResource
@@ -55,7 +56,8 @@ class RecommendedMovieViewModel
@AssistedInject
constructor(
@ApplicationContext context: Context,
- private val api: ApiClient,
+ api: ApiClient,
+ musicService: MusicService,
private val serverRepository: ServerRepository,
private val preferencesDataStore: DataStore,
private val suggestionService: SuggestionService,
@@ -67,9 +69,11 @@ class RecommendedMovieViewModel
mediaManagementService: MediaManagementService,
) : RecommendedViewModel(
context,
+ api,
navigationManager,
favoriteWatchManager,
mediaReportService,
+ musicService,
backdropService,
mediaManagementService,
) {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt
new file mode 100644
index 00000000..e659347b
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt
@@ -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,
+ 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>(
+ 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>()
+ 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..
+ 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(
+ creationCallback = { it.create(parentId) },
+ ),
+) {
+ RecommendedContent(
+ preferences = preferences,
+ viewModel = viewModel,
+ onFocusPosition = onFocusPosition,
+ modifier = modifier,
+ )
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt
index db7f7b4f..a48af22d 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt
@@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.LatestNextUpService
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MediaReportService
+import com.github.damontecres.wholphin.services.MusicService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SuggestionService
import com.github.damontecres.wholphin.services.SuggestionsResource
@@ -58,7 +59,8 @@ class RecommendedTvShowViewModel
@AssistedInject
constructor(
@ApplicationContext context: Context,
- private val api: ApiClient,
+ api: ApiClient,
+ musicService: MusicService,
private val serverRepository: ServerRepository,
private val preferencesDataStore: DataStore,
private val lastestNextUpService: LatestNextUpService,
@@ -71,9 +73,11 @@ class RecommendedTvShowViewModel
mediaManagementService: MediaManagementService,
) : RecommendedViewModel(
context,
+ api,
navigationManager,
favoriteWatchManager,
mediaReportService,
+ musicService,
backdropService,
mediaManagementService,
) {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SelectedLeadingContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SelectedLeadingContent.kt
index efc2ddac..d06fd652 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SelectedLeadingContent.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SelectedLeadingContent.kt
@@ -13,6 +13,11 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import androidx.tv.material3.LocalContentColor
+/**
+ * Shows a dot indicator
+ *
+ * Intended for using within the `leadingContent` of a [androidx.tv.material3.ListItem]
+ */
@Composable
fun BoxScope.SelectedLeadingContent(
selected: Boolean,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt
index 64cfc78b..f87f5c98 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt
@@ -126,16 +126,19 @@ data class SliderColors(
@Composable
fun default() =
SliderColors(
- activeFocused = SliderActiveColor(true),
- activeUnfocused = SliderActiveColor(false),
- inactiveFocused = SliderInactiveColor(true),
- inactiveUnfocused = SliderInactiveColor(false),
+ activeFocused = sliderActiveColor(true),
+ activeUnfocused = sliderActiveColor(false),
+ inactiveFocused = sliderInactiveColor(true),
+ inactiveUnfocused = sliderInactiveColor(false),
)
}
}
+/**
+ * Determines the active color for the slider. This is the "filled" left side of the slider
+ */
@Composable
-fun SliderActiveColor(focused: Boolean): Color {
+fun sliderActiveColor(focused: Boolean): Color {
val theme = LocalTheme.current
return when (theme) {
AppThemeColors.UNRECOGNIZED,
@@ -165,8 +168,11 @@ fun SliderActiveColor(focused: Boolean): Color {
}
}
+/**
+ * Determines the inactive color for the slider. This is background of "unfilled" right side of the slider.
+ */
@Composable
-fun SliderInactiveColor(focused: Boolean): Color {
+fun sliderInactiveColor(focused: Boolean): Color {
val theme = LocalTheme.current
return when (theme) {
AppThemeColors.UNRECOGNIZED,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt
new file mode 100644
index 00000000..b52c7a8e
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt
@@ -0,0 +1,220 @@
+package com.github.damontecres.wholphin.ui.components
+
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.times
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.github.damontecres.wholphin.data.ServerRepository
+import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.data.model.createStudioDestination
+import com.github.damontecres.wholphin.services.ImageUrlService
+import com.github.damontecres.wholphin.services.NavigationManager
+import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
+import com.github.damontecres.wholphin.ui.SlimItemFields
+import com.github.damontecres.wholphin.ui.cards.StudioCard
+import com.github.damontecres.wholphin.ui.detail.CardGrid
+import com.github.damontecres.wholphin.ui.detail.CardGridItem
+import com.github.damontecres.wholphin.ui.setValueOnMain
+import com.github.damontecres.wholphin.ui.tryRequestFocus
+import com.github.damontecres.wholphin.util.GetStudiosRequestHandler
+import com.github.damontecres.wholphin.util.LoadingExceptionHandler
+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 kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.jellyfin.sdk.api.client.ApiClient
+import org.jellyfin.sdk.api.client.extensions.userLibraryApi
+import org.jellyfin.sdk.model.api.BaseItemKind
+import org.jellyfin.sdk.model.api.ImageType
+import org.jellyfin.sdk.model.api.request.GetStudiosRequest
+import java.util.UUID
+
+@HiltViewModel(assistedFactory = StudioViewModel.Factory::class)
+class StudioViewModel
+ @AssistedInject
+ constructor(
+ private val api: ApiClient,
+ private val imageUrlService: ImageUrlService,
+ private val serverRepository: ServerRepository,
+ val navigationManager: NavigationManager,
+ @Assisted private val itemId: UUID,
+ @Assisted private val includeItemTypes: List?,
+ ) : ViewModel() {
+ @AssistedFactory
+ interface Factory {
+ fun create(
+ itemId: UUID,
+ includeItemTypes: List?,
+ ): StudioViewModel
+ }
+
+ val item = MutableLiveData(null)
+ val loading = MutableLiveData(LoadingState.Pending)
+ val studios = MutableLiveData>(listOf())
+
+ fun init(cardWidthPx: Int) {
+ loading.value = LoadingState.Loading
+ viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to fetch genres")) {
+ val item =
+ api.userLibraryApi.getItem(itemId = itemId).content.let {
+ BaseItem(it, false)
+ }
+ this@StudioViewModel.item.setValueOnMain(item)
+ val request =
+ GetStudiosRequest(
+ userId = serverRepository.currentUser.value?.id,
+ parentId = itemId,
+ fields = SlimItemFields,
+ includeItemTypes = includeItemTypes,
+ )
+ val studios =
+ GetStudiosRequestHandler
+ .execute(api, request)
+ .content.items
+ .map {
+ val imageUrl =
+ imageUrlService.getItemImageUrl(
+ itemId = it.id,
+ imageType = ImageType.THUMB,
+ fillWidth = cardWidthPx,
+ )
+ Studio(it.id, it.name ?: "", imageUrl)
+ }
+ withContext(Dispatchers.Main) {
+ this@StudioViewModel.studios.value = studios
+ loading.value = LoadingState.Success
+ }
+ }
+ }
+
+ suspend fun positionOfLetter(letter: Char): Int =
+ withContext(Dispatchers.IO) {
+ val request =
+ GetStudiosRequest(
+ userId = serverRepository.currentUser.value?.id,
+ parentId = itemId,
+ nameLessThan = letter.toString(),
+ limit = 0,
+ enableTotalRecordCount = true,
+ includeItemTypes = includeItemTypes,
+ )
+ val result by GetStudiosRequestHandler.execute(api, request)
+ return@withContext result.totalRecordCount
+ }
+ }
+
+@Stable
+data class Studio(
+ val id: UUID,
+ val name: String,
+ val imageUrl: String?,
+) : CardGridItem {
+ override val gridId: String get() = id.toString()
+ override val playable: Boolean = false
+ override val sortName: String get() = name
+}
+
+@Composable
+fun StudioCardGrid(
+ itemId: UUID,
+ includeItemTypes: List?,
+ modifier: Modifier = Modifier,
+ viewModel: StudioViewModel =
+ hiltViewModel(
+ creationCallback = { it.create(itemId, includeItemTypes) },
+ ),
+) {
+ val columns = 4
+ val spacing = 16.dp
+ val density = LocalDensity.current
+ val configuration = LocalConfiguration.current
+ val cardWidthPx =
+ remember {
+ with(density) {
+ // Grid has 16dp padding on either side & 16dp spacing between 4 cards
+ // This isn't exact though because it doesn't account for nav drawer or letters, but it's close and the calculation is much faster
+ // E.g. on 1080p, this results in 440px versus 395px actual, so only minimal scaling down is required
+ (configuration.screenWidthDp.dp - (2 * 16.dp + 3 * spacing))
+ .div(columns)
+ .roundToPx()
+ }
+ }
+ OneTimeLaunchedEffect {
+ viewModel.init(cardWidthPx)
+ }
+ val loading by viewModel.loading.observeAsState(LoadingState.Pending)
+ val studios by viewModel.studios.observeAsState(listOf())
+
+ val gridFocusRequester = remember { FocusRequester() }
+ when (val st = loading) {
+ LoadingState.Pending,
+ LoadingState.Loading,
+ -> {
+ LoadingPage(modifier.focusable())
+ }
+
+ is LoadingState.Error -> {
+ ErrorMessage(st, modifier.focusable())
+ }
+
+ LoadingState.Success -> {
+ Box(modifier = modifier) {
+ LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() }
+ val item by viewModel.item.observeAsState(null)
+ CardGrid(
+ pager = studios,
+ onClickItem = { _, studio ->
+ viewModel.navigationManager.navigateTo(
+ createStudioDestination(
+ studioId = studio.id,
+ name = studio.name,
+ parentId = itemId,
+ parentName = item?.title,
+ includeItemTypes = includeItemTypes,
+ ),
+ )
+ },
+ onLongClickItem = { _, _ -> },
+ onClickPlay = { _, _ -> },
+ letterPosition = { viewModel.positionOfLetter(it) },
+ gridFocusRequester = gridFocusRequester,
+ showJumpButtons = false,
+ showLetterButtons = true,
+ modifier = Modifier.fillMaxSize(),
+ initialPosition = 0,
+ positionCallback = { columns, position ->
+ },
+ columns = columns,
+ spacing = spacing,
+ cardContent = { item: Studio?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier ->
+ StudioCard(
+ studio = item,
+ onClick = onClick,
+ onLongClick = onLongClick,
+ modifier = mod,
+ )
+ },
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt
index 25b51698..f54fda5b 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt
@@ -19,9 +19,9 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -40,7 +40,6 @@ import androidx.compose.ui.unit.sp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.ui.PreviewTvSpec
-import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus
import timber.log.Timber
@@ -60,6 +59,11 @@ fun TabRow(
}
}
var rowHasFocus by remember { mutableStateOf(false) }
+
+ val currentSelectedTabIndex by rememberUpdatedState(selectedTabIndex)
+ val currentFocusRequesters by rememberUpdatedState(focusRequesters)
+ val currentOnClick by rememberUpdatedState(onClick)
+
LazyRow(
state = state,
modifier =
@@ -71,14 +75,16 @@ fun TabRow(
onEnter = {
// If entering from left or right, use last or first tab
// Otherwise use the selected tab
- Timber.v("onEnter requestedFocusDirection=$requestedFocusDirection, selectedTabIndex=$selectedTabIndex")
+ val index = currentSelectedTabIndex
+ val requesters = currentFocusRequesters
+ Timber.v("onEnter requestedFocusDirection=$requestedFocusDirection, selectedTabIndex=$index")
val focusRequester =
if (requestedFocusDirection == FocusDirection.Left) {
- focusRequesters.lastOrNull()
+ requesters.lastOrNull()
} else if (requestedFocusDirection == FocusDirection.Right) {
- focusRequesters.firstOrNull()
+ requesters.firstOrNull()
} else {
- focusRequesters.getOrNull(selectedTabIndex)
+ requesters.getOrNull(index)
}
(focusRequester ?: FocusRequester.Default).tryRequestFocus()
}
@@ -86,15 +92,19 @@ fun TabRow(
) {
itemsIndexed(tabs) { index, tabTitle ->
val interactionSource = remember { MutableInteractionSource() }
+ val onTabClick =
+ remember(index) {
+ {
+ currentOnClick(index)
+ }
+ }
Tab(
title = tabTitle,
selected = index == selectedTabIndex,
rowActive = rowHasFocus,
interactionSource = interactionSource,
- onClick = {
- onClick.invoke(index)
- },
- modifier = Modifier.focusRequester(focusRequesters[index]),
+ onClick = onTabClick,
+ modifier = Modifier.focusRequester(focusRequesters.getOrElse(index) { FocusRequester() }),
)
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt
index f5791e04..f70a4f6a 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt
@@ -12,6 +12,9 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.ui.util.LocalClock
+/**
+ * Displays the [LocalClock] in the upper right corner of the parent [androidx.compose.foundation.layout.Box]
+ */
@Composable
fun BoxScope.TimeDisplay(modifier: Modifier = Modifier) {
val timeString by LocalClock.current.timeString
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt
new file mode 100644
index 00000000..5af10b8b
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt
@@ -0,0 +1,95 @@
+package com.github.damontecres.wholphin.ui.components
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import coil3.compose.AsyncImage
+import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.ui.LocalImageUrlService
+import org.jellyfin.sdk.model.api.BaseItemKind
+import org.jellyfin.sdk.model.api.ImageType
+
+@Composable
+fun TitleOrLogo(
+ title: String?,
+ logoImageUrl: String?,
+ showLogo: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ var imageError by remember { mutableStateOf(false) }
+ Box(
+ modifier = modifier.heightIn(max = HeaderUtils.logoHeight),
+ ) {
+ if (showLogo && logoImageUrl != null && !imageError) {
+ AsyncImage(
+ model = logoImageUrl,
+ contentDescription = title,
+ contentScale = ContentScale.Fit,
+ modifier =
+ Modifier
+ .height(HeaderUtils.logoHeight)
+ .widthIn(max = 320.dp),
+ )
+ } else {
+ Title(title, Modifier)
+ }
+ }
+}
+
+@Composable
+private fun Title(
+ title: String?,
+ modifier: Modifier = Modifier,
+) {
+ Text(
+ text = title ?: "",
+ color = MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ modifier = modifier,
+ )
+}
+
+@Composable
+fun TitleOrLogo(
+ item: BaseItem?,
+ showLogo: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ val logoImageUrl = rememberLogoUrl(item)
+ TitleOrLogo(
+ title = item?.title,
+ logoImageUrl = logoImageUrl,
+ showLogo = showLogo,
+ modifier = modifier,
+ )
+}
+
+@Composable
+fun rememberLogoUrl(item: BaseItem?): String? {
+ val imageUrlService = LocalImageUrlService.current
+ return remember(item?.id) {
+ if (item?.type == BaseItemKind.EPISODE && item.data.seriesId != null && item.data.parentLogoImageTag != null) {
+ imageUrlService.getItemImageUrl(item.data.seriesId!!, ImageType.LOGO)
+ } else if (ImageType.LOGO in item?.data?.imageTags.orEmpty()) {
+ imageUrlService.getItemImageUrl(item, ImageType.LOGO)
+ } else {
+ null
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt
index e4e6d350..74c00328 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt
@@ -39,6 +39,11 @@ import com.github.damontecres.wholphin.ui.tryRequestFocus
import kotlinx.serialization.Serializable
import org.jellyfin.sdk.model.api.ImageType
+/**
+ * A dialog that shows the controls for making changes to a [ViewOptions] object. The caller must manage the state of the [ViewOptions].
+ *
+ * It displays the [AppPreference] objects from [ViewOptions.OPTIONS]
+ */
@Composable
fun ViewOptionsDialog(
viewOptions: ViewOptions,
@@ -105,6 +110,11 @@ fun ViewOptionsDialog(
}
}
+/**
+ * Stores the customizable view changes from the user
+ *
+ * This is used to determine how the UI looks such as card size and shape
+ */
@Serializable
data class ViewOptions(
val columns: Int = 6,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/AddPlaylistViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/AddPlaylistViewModel.kt
index 9e2141b5..5c4721c5 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/AddPlaylistViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/AddPlaylistViewModel.kt
@@ -19,6 +19,10 @@ import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
+/**
+ * A supplementary [ViewModel] for adding items to a server playlist
+ * @see com.github.damontecres.wholphin.ui.detail.PlaylistDialog
+ */
@HiltViewModel
class AddPlaylistViewModel
@Inject
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt
index fa32c595..67e4cc3e 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt
@@ -19,9 +19,12 @@ import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
-import com.github.damontecres.wholphin.ui.byteRateSuffixes
+import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.data.model.studioNames
import com.github.damontecres.wholphin.ui.components.ScrollableDialog
+import com.github.damontecres.wholphin.ui.formatBitrate
import com.github.damontecres.wholphin.ui.formatBytes
+import com.github.damontecres.wholphin.ui.formatDate
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatAudioCodec
@@ -33,6 +36,7 @@ import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.VideoRange
import org.jellyfin.sdk.model.api.VideoRangeType
import org.jellyfin.sdk.model.extensions.ticks
+import java.time.LocalDateTime
import java.util.Locale
data class ItemDetailsDialogInfo(
@@ -40,8 +44,22 @@ data class ItemDetailsDialogInfo(
val overview: String?,
val genres: List,
val files: List,
-)
+ val studios: List = emptyList(),
+ val lastPlayed: LocalDateTime? = null,
+) {
+ constructor(item: BaseItem) : this(
+ title = item.name ?: "",
+ overview = item.data.overview,
+ genres = item.data.genres.orEmpty(),
+ files = item.data.mediaSources.orEmpty(),
+ studios = item.studioNames,
+ lastPlayed = item.data.userData?.lastPlayedDate,
+ )
+}
+/**
+ * Dialog showing metadata about an item
+ */
@Composable
fun ItemDetailsDialog(
info: ItemDetailsDialogInfo,
@@ -58,6 +76,7 @@ fun ItemDetailsDialog(
val bitrateLabel = stringResource(R.string.bitrate)
val unknown = stringResource(R.string.unknown)
val runtimeLabel = stringResource(R.string.runtime_sort)
+ val lastPlayedLabel = stringResource(R.string.last_played)
ScrollableDialog(
onDismissRequest = onDismissRequest,
@@ -71,6 +90,12 @@ fun ItemDetailsDialog(
text = info.title,
style = MaterialTheme.typography.headlineSmall,
)
+ if (info.studios.isNotEmpty()) {
+ Text(
+ text = info.studios.joinToString(", "),
+ style = MaterialTheme.typography.titleMedium,
+ )
+ }
if (info.genres.isNotEmpty()) {
Text(
text = info.genres.joinToString(", "),
@@ -83,6 +108,14 @@ fun ItemDetailsDialog(
style = MaterialTheme.typography.bodyMedium,
)
}
+ val lastPlayed =
+ remember(info.lastPlayed) { info.lastPlayed?.let { formatDate(it) } }
+ if (lastPlayed != null) {
+ Text(
+ text = "$lastPlayedLabel: $lastPlayed",
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
}
}
@@ -116,7 +149,7 @@ fun ItemDetailsDialog(
}
source.bitrate?.let {
add(
- bitrateLabel to formatBytes(it, byteRateSuffixes),
+ bitrateLabel to formatBitrate(it),
)
}
source.runTimeTicks?.let {
@@ -297,7 +330,7 @@ private fun buildVideoStreamInfo(
val aspectRatio = calculateAspectRatio(stream.width!!, stream.height!!)
add(aspectRatioLabel to aspectRatio)
}
- stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) }
+ stream.bitRate?.let { add(bitrateLabel to formatBitrate(it)) }
stream.averageFrameRate?.let {
add(framerateLabel to String.format(Locale.getDefault(), "%.3f", it))
}
@@ -405,7 +438,7 @@ private fun buildAudioStreamInfo(
stream.channelLayout?.let { add(layoutLabel to it) }
stream.channels?.let { add(channelsLabel to it.toString()) }
stream.profile?.let { add(profileLabel to it) }
- stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) }
+ stream.bitRate?.let { add(bitrateLabel to formatBitrate(it)) }
stream.sampleRate?.let { add(sampleRateLabel to "$it $sampleRateUnit") }
stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) }
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt
index 17f3d0ee..ee4b8b26 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt
@@ -99,6 +99,45 @@ val BoxSetSortOptions =
ItemSortBy.RANDOM,
)
+val AlbumSortOptions =
+ listOf(
+ ItemSortBy.SORT_NAME,
+ ItemSortBy.PREMIERE_DATE,
+ ItemSortBy.DATE_CREATED,
+ ItemSortBy.DATE_PLAYED,
+ ItemSortBy.ALBUM_ARTIST,
+ ItemSortBy.COMMUNITY_RATING,
+ ItemSortBy.CRITIC_RATING,
+ ItemSortBy.RANDOM,
+ )
+
+val ArtistSortOptions =
+ listOf(
+ ItemSortBy.SORT_NAME,
+ ItemSortBy.PREMIERE_DATE,
+ ItemSortBy.DATE_CREATED,
+ ItemSortBy.DATE_PLAYED,
+ ItemSortBy.COMMUNITY_RATING,
+ ItemSortBy.CRITIC_RATING,
+ ItemSortBy.RANDOM,
+ )
+
+val SongSortOptions =
+ listOf(
+ ItemSortBy.SORT_NAME,
+ ItemSortBy.PREMIERE_DATE,
+ ItemSortBy.ALBUM,
+ ItemSortBy.ALBUM_ARTIST,
+ ItemSortBy.ARTIST,
+ ItemSortBy.DATE_CREATED,
+ ItemSortBy.DATE_PLAYED,
+ ItemSortBy.COMMUNITY_RATING,
+ ItemSortBy.CRITIC_RATING,
+ ItemSortBy.PLAY_COUNT,
+ ItemSortBy.RUNTIME,
+ ItemSortBy.RANDOM,
+ )
+
@StringRes
fun getStringRes(sort: ItemSortBy): Int =
when (sort) {
@@ -132,5 +171,11 @@ fun getStringRes(sort: ItemSortBy): Int =
ItemSortBy.DEFAULT -> R.string.default_track
+ ItemSortBy.ALBUM -> R.string.album
+
+ ItemSortBy.ALBUM_ARTIST -> R.string.album_artist
+
+ ItemSortBy.ARTIST -> R.string.artist
+
else -> throw IllegalArgumentException("Unsupported sort option: $sort")
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt
index ac72a144..41f5e534 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt
@@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.ui.detail
+import android.view.KeyEvent
import androidx.annotation.StringRes
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
@@ -51,7 +52,6 @@ import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
@@ -85,6 +85,9 @@ interface CardGridItem {
val sortName: String
}
+/**
+ * Shows a vertical grid of [CardGridItem]s
+ */
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun CardGrid(
@@ -199,6 +202,25 @@ fun CardGrid(
}
}
+ val jumpToLetter: (Char) -> Unit =
+ remember {
+ { letter: Char ->
+ scope.launch(ExceptionHandler()) {
+ val jumpPosition =
+ withContext(Dispatchers.IO) {
+ letterPosition.invoke(letter)
+ }
+ Timber.d("Alphabet jump to $jumpPosition")
+ if (jumpPosition >= 0) {
+ pager.getOrNull(jumpPosition)
+ gridState.scrollToItem(jumpPosition)
+ focusOn(jumpPosition)
+ alphabetFocus = true
+ }
+ }
+ }
+ }
+
if (pager.isEmpty()) {
Box(
contentAlignment = Alignment.Center,
@@ -257,6 +279,12 @@ fun CardGrid(
} else if (useJumpRemoteButtons && isBackwardButton(it)) {
jump(-jump1)
return@onKeyEvent true
+ } else if (showLetterButtons && pager.isNotEmpty() &&
+ it.nativeKeyEvent.keyCode in (KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z)
+ ) {
+ val letter = it.nativeKeyEvent.unicodeChar.toChar()
+ jumpToLetter.invoke(letter)
+ return@onKeyEvent true
} else {
return@onKeyEvent false
}
@@ -372,8 +400,7 @@ fun CardGrid(
}
}
}
- val context = LocalContext.current
- val letters = context.getString(R.string.jump_letters)
+ val letters = stringResource(R.string.jump_letters)
// Letters
val currentLetter =
remember(focusedIndex) {
@@ -383,12 +410,10 @@ fun CardGrid(
?.firstOrNull()
?.uppercaseChar()
?.let {
- if (it >= '0' && it <= '9') {
- '#'
- } else if (it >= 'A' && it <= 'Z') {
- it
- } else {
- null
+ when (it) {
+ in '0'..'9' -> '#'
+ in 'A'..'Z' -> it
+ else -> null
}
}
?: letters[0]
@@ -402,21 +427,7 @@ fun CardGrid(
.align(Alignment.CenterVertically)
.padding(start = 16.dp),
// Add end padding to push away from edge
- letterClicked = { letter ->
- scope.launch(ExceptionHandler()) {
- val jumpPosition =
- withContext(Dispatchers.IO) {
- letterPosition.invoke(letter)
- }
- Timber.d("Alphabet jump to $jumpPosition")
- if (jumpPosition >= 0) {
- pager.getOrNull(jumpPosition)
- gridState.scrollToItem(jumpPosition)
- focusOn(jumpPosition)
- alphabetFocus = true
- }
- }
- },
+ letterClicked = jumpToLetter,
)
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt
deleted file mode 100644
index 07c24c37..00000000
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt
+++ /dev/null
@@ -1,48 +0,0 @@
-package com.github.damontecres.wholphin.ui.detail
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Modifier
-import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
-import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
-import com.github.damontecres.wholphin.preferences.UserPreferences
-import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
-import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster
-import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions
-import com.github.damontecres.wholphin.ui.data.SortAndDirection
-import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
-import org.jellyfin.sdk.model.api.ItemSortBy
-import org.jellyfin.sdk.model.api.SortOrder
-import java.util.UUID
-
-@Composable
-fun CollectionFolderBoxSet(
- preferences: UserPreferences,
- itemId: UUID,
- recursive: Boolean,
- modifier: Modifier = Modifier,
- filter: CollectionFolderFilter = CollectionFolderFilter(),
- preferencesViewModel: PreferencesViewModel = hiltViewModel(),
- playEnabled: Boolean = false,
-) {
- var showHeader by remember { mutableStateOf(true) }
- CollectionFolderGrid(
- preferences = preferences,
- onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) },
- itemId = itemId,
- initialFilter = filter,
- showTitle = showHeader,
- recursive = recursive,
- sortOptions = BoxSetSortOptions,
- initialSortAndDirection = SortAndDirection(ItemSortBy.DEFAULT, SortOrder.ASCENDING),
- modifier = modifier,
- positionCallback = { columns, position ->
- showHeader = position < columns
- },
- defaultViewOptions = ViewOptionsPoster,
- playEnabled = playEnabled,
- )
-}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt
new file mode 100644
index 00000000..30534ffc
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt
@@ -0,0 +1,245 @@
+package com.github.damontecres.wholphin.ui.detail
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
+import com.github.damontecres.wholphin.data.model.GetItemsFilter
+import com.github.damontecres.wholphin.preferences.UserPreferences
+import com.github.damontecres.wholphin.services.MusicService
+import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
+import com.github.damontecres.wholphin.ui.components.ErrorMessage
+import com.github.damontecres.wholphin.ui.components.GenreCardGrid
+import com.github.damontecres.wholphin.ui.components.RecommendedMusic
+import com.github.damontecres.wholphin.ui.components.TabRow
+import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare
+import com.github.damontecres.wholphin.ui.data.AlbumSortOptions
+import com.github.damontecres.wholphin.ui.data.ArtistSortOptions
+import com.github.damontecres.wholphin.ui.data.SongSortOptions
+import com.github.damontecres.wholphin.ui.launchDefault
+import com.github.damontecres.wholphin.ui.logTab
+import com.github.damontecres.wholphin.ui.nav.Destination
+import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
+import dagger.hilt.android.lifecycle.HiltViewModel
+import org.jellyfin.sdk.model.api.BaseItemKind
+import javax.inject.Inject
+
+@HiltViewModel
+class CollectionFolderMusicViewModel
+ @Inject
+ constructor(
+ private val musicService: MusicService,
+ ) : ViewModel() {
+ fun play(item: BaseItem) {
+ if (item.type == BaseItemKind.AUDIO) {
+ viewModelScope.launchDefault {
+ musicService.setQueue(listOf(item), false)
+ }
+ }
+ }
+ }
+
+@Composable
+fun CollectionFolderMusic(
+ preferences: UserPreferences,
+ destination: Destination.MediaItem,
+ modifier: Modifier = Modifier,
+ viewModel: CollectionFolderMusicViewModel = hiltViewModel(),
+ preferencesViewModel: PreferencesViewModel = hiltViewModel(),
+) {
+ val rememberedTabIndex =
+ remember { preferencesViewModel.getRememberedTab(preferences, destination.itemId, 0) }
+
+ val tabs =
+ listOf(
+ stringResource(R.string.recommended),
+ stringResource(R.string.albums),
+ stringResource(R.string.artists),
+ stringResource(R.string.genres),
+ stringResource(R.string.songs),
+ )
+ var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
+ val focusRequester = remember { FocusRequester() }
+ val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } }
+
+ val firstTabFocusRequester = remember { FocusRequester() }
+// LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
+
+ LaunchedEffect(selectedTabIndex) {
+ logTab("music", selectedTabIndex)
+ preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
+ preferencesViewModel.backdropService.clearBackdrop()
+ }
+
+ var showHeader by rememberSaveable { mutableStateOf(true) }
+
+ Column(
+ modifier = modifier,
+ ) {
+ AnimatedVisibility(
+ showHeader,
+ enter = expandVertically(),
+ exit = shrinkVertically(),
+ ) {
+ TabRow(
+ selectedTabIndex = selectedTabIndex,
+ modifier =
+ Modifier
+ .padding(vertical = 16.dp)
+ .focusRequester(firstTabFocusRequester),
+ tabs = tabs,
+ onClick = { selectedTabIndex = it },
+ focusRequesters = tabFocusRequesters,
+ )
+ }
+ when (selectedTabIndex) {
+ // Recommended
+ 0 -> {
+ RecommendedMusic(
+ preferences = preferences,
+ parentId = destination.itemId,
+ onFocusPosition = { pos ->
+ showHeader = pos.row < 1
+ },
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .focusRequester(focusRequester),
+ )
+ }
+
+ // Albums
+ 1 -> {
+ CollectionFolderGrid(
+ preferences = preferences,
+ onClickItem = { _, item ->
+ preferencesViewModel.navigationManager.navigateTo(item.destination())
+ },
+ itemId = destination.itemId,
+ viewModelKey = "${destination.itemId}_albums",
+ initialFilter =
+ CollectionFolderFilter(
+ filter =
+ GetItemsFilter(
+ includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
+ ),
+ ),
+ showTitle = false,
+ recursive = true,
+ sortOptions = AlbumSortOptions,
+ defaultViewOptions = ViewOptionsSquare,
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .focusRequester(focusRequester),
+ positionCallback = { columns, position ->
+ showHeader = position < columns
+ },
+ playEnabled = true,
+ focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
+ )
+ }
+
+ // Artists
+ 2 -> {
+ CollectionFolderGrid(
+ preferences = preferences,
+ onClickItem = { _, item ->
+ preferencesViewModel.navigationManager.navigateTo(item.destination())
+ },
+ itemId = destination.itemId,
+ viewModelKey = "${destination.itemId}_artists",
+ initialFilter =
+ CollectionFolderFilter(
+ filter =
+ GetItemsFilter(
+ includeItemTypes = listOf(BaseItemKind.MUSIC_ARTIST),
+ ),
+ ),
+ showTitle = false,
+ recursive = true,
+ sortOptions = ArtistSortOptions,
+ defaultViewOptions = ViewOptionsSquare,
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .focusRequester(focusRequester),
+ positionCallback = { columns, position ->
+ showHeader = position < columns
+ },
+ playEnabled = false,
+ focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
+ )
+ }
+
+ // Genres
+ 3 -> {
+ GenreCardGrid(
+ itemId = destination.itemId,
+ includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .focusRequester(focusRequester),
+ )
+ }
+
+ // Songs
+ 4 -> {
+ CollectionFolderGrid(
+ preferences = preferences,
+ onClickItem = { _, item ->
+ viewModel.play(item)
+ },
+ itemId = destination.itemId,
+ viewModelKey = "${destination.itemId}_songs",
+ initialFilter =
+ CollectionFolderFilter(
+ filter =
+ GetItemsFilter(
+ includeItemTypes = listOf(BaseItemKind.AUDIO),
+ ),
+ ),
+ showTitle = false,
+ recursive = true,
+ sortOptions = SongSortOptions,
+ defaultViewOptions = ViewOptionsSquare,
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .focusRequester(focusRequester),
+ positionCallback = { columns, position ->
+ showHeader = position < columns
+ },
+ playEnabled = true,
+ focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
+ )
+ }
+
+ else -> {
+ ErrorMessage("Invalid tab index $selectedTabIndex", null)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt
index 68b9dd18..cbc87bae 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt
@@ -21,6 +21,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.data.filter.DefaultTvFilterOptions
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilter
@@ -29,6 +30,7 @@ import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.GenreCardGrid
import com.github.damontecres.wholphin.ui.components.RecommendedTvShow
+import com.github.damontecres.wholphin.ui.components.StudioCardGrid
import com.github.damontecres.wholphin.ui.components.TabRow
import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster
import com.github.damontecres.wholphin.ui.data.SeriesSortOptions
@@ -53,6 +55,7 @@ fun CollectionFolderTv(
stringResource(R.string.recommended),
stringResource(R.string.library),
stringResource(R.string.genres),
+ stringResource(R.string.studios),
)
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
val focusRequester = remember { FocusRequester() }
@@ -123,6 +126,7 @@ fun CollectionFolderTv(
showTitle = false,
recursive = true,
sortOptions = SeriesSortOptions,
+ filterOptions = DefaultTvFilterOptions,
defaultViewOptions = ViewOptionsPoster,
modifier =
Modifier
@@ -151,6 +155,18 @@ fun CollectionFolderTv(
)
}
+ // Studios
+ 3 -> {
+ StudioCardGrid(
+ itemId = destination.itemId,
+ includeItemTypes = listOf(BaseItemKind.SERIES),
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .focusRequester(focusRequester),
+ )
+ }
+
else -> {
ErrorMessage("Invalid tab index $selectedTabIndex", null)
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt
index 10425c96..e2f5e398 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt
@@ -64,6 +64,7 @@ import org.jellyfin.sdk.model.DeviceInfo
import timber.log.Timber
import java.io.BufferedReader
import java.io.InputStreamReader
+import java.util.Date
import javax.inject.Inject
@HiltViewModel
@@ -87,6 +88,34 @@ class DebugViewModel
display.supportedModes.orEmpty()
}
+ val av1Included by lazy {
+ try {
+ Class.forName("androidx.media3.decoder.av1.Libdav1dVideoRenderer")
+ true
+ } catch (_: ClassNotFoundException) {
+ false
+ }
+ }
+
+ val ffmpegIncluded by lazy {
+ try {
+ Class.forName("androidx.media3.decoder.ffmpeg.FfmpegAudioRenderer")
+ true
+ } catch (_: ClassNotFoundException) {
+ false
+ }
+ }
+
+ val libMpvLoaded by lazy {
+ try {
+ System.loadLibrary("player")
+ System.loadLibrary("mpv")
+ true
+ } catch (_: Exception) {
+ false
+ }
+ }
+
init {
viewModelScope.launchIO {
serverRepository.currentUser.value?.rowId?.let {
@@ -380,6 +409,7 @@ fun DebugPage(
style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.onSurface,
)
+ val buildTime = Date(BuildConfig.BUILD_TIME)
val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0)
val installInfo =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
@@ -401,6 +431,11 @@ fun DebugPage(
"Version Code: ${pkgInfo.versionCodeLong}",
"ClientInfo: ${viewModel.clientInfo}",
"Build type: ${BuildConfig.BUILD_TYPE}",
+ "Build flavor: ${BuildConfig.FLAVOR}",
+ "Build time: $buildTime",
+ "FFMPEG included: ${viewModel.ffmpegIncluded}",
+ "AV1 included: ${viewModel.av1Included}",
+ "libmpv loaded: ${viewModel.libMpvLoaded}",
"Debug enabled: ${BuildConfig.DEBUG}",
"ABIs: ${Build.SUPPORTED_ABIS.toList()}",
) + installInfo
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt
index 5fe13871..049569d9 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt
@@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.detail
import android.content.Context
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
+import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Info
@@ -31,6 +32,8 @@ data class MoreDialogActions(
val onSendMediaInfo: (UUID) -> Unit,
val onClickDelete: (BaseItem) -> Unit,
val onClickGoTo: (BaseItem) -> Unit = { navigateTo(it.destination()) },
+ val onClickRemoveFromNextUp: (BaseItem) -> Unit = {},
+ val onClickAddToQueue: (BaseItem) -> Unit = {},
)
enum class ClearChosenStreams {
@@ -49,9 +52,8 @@ enum class ClearChosenStreams {
* If there are any (ie one or more) subtitle tracks, adds an option to disable or pick one
*
* @param item the media item to build for, typically an Episode or Movie
- * @param series the item's series or null if not a TV episode; a non-null value will include a "Go to Series" option
+ * @param seriesId the item's series or null if not a TV episode; a non-null value will include a "Go to Series" option
* @param sourceId the item's media source UUID
- * @param navigateTo a function to trigger a navigation
* @param onChooseVersion callback to pick a version of the item
* @param onChooseTracks callback to pick a track for the given type of the item
* @param onShowOverview callback to show overview dialog with media information
@@ -170,6 +172,38 @@ fun buildMoreDialogItems(
actions.onClickFavorite.invoke(item.id, !favorite)
},
)
+ item.data.albumId?.let { albumId ->
+ add(
+ DialogItem(
+ context.getString(R.string.go_to_album),
+ R.string.fa_compact_disc,
+ ) {
+ actions.navigateTo(
+ Destination.MediaItem(
+ albumId,
+ BaseItemKind.MUSIC_ALBUM,
+ null,
+ ),
+ )
+ },
+ )
+ }
+ item.data.artistItems?.firstOrNull()?.id?.let { artistId ->
+ add(
+ DialogItem(
+ context.getString(R.string.go_to_artist),
+ R.string.fa_user,
+ ) {
+ actions.navigateTo(
+ Destination.MediaItem(
+ artistId,
+ BaseItemKind.MUSIC_ARTIST,
+ null,
+ ),
+ )
+ },
+ )
+ }
seriesId?.let {
add(
DialogItem(
@@ -239,6 +273,8 @@ fun buildMoreDialogItemsForHome(
favorite: Boolean,
canDelete: Boolean,
actions: MoreDialogActions,
+ canRemoveContinueWatching: Boolean = false,
+ canRemoveNextUp: Boolean = false,
): List =
buildList {
val itemId = item.id
@@ -297,6 +333,16 @@ fun buildMoreDialogItemsForHome(
)
}
}
+ if (item.type == BaseItemKind.MUSIC_ALBUM) {
+ add(
+ DialogItem(
+ context.getString(R.string.add_to_queue),
+ Icons.Default.Add,
+ ) {
+ actions.onClickAddToQueue(item)
+ },
+ )
+ }
add(
DialogItem(
text = R.string.add_to_playlist,
@@ -316,6 +362,26 @@ fun buildMoreDialogItemsForHome(
},
)
}
+ if (canRemoveContinueWatching && !watched && playbackPosition > Duration.ZERO) {
+ add(
+ DialogItem(
+ text = R.string.remove_continue_watching,
+ iconStringRes = R.string.fa_eye,
+ ) {
+ actions.onClickWatch.invoke(itemId, false)
+ },
+ )
+ }
+ if (canRemoveNextUp && item.type == BaseItemKind.EPISODE && item.data.seriesId != null) {
+ add(
+ DialogItem(
+ text = R.string.remove_next_up,
+ iconStringRes = R.string.fa_tag,
+ ) {
+ actions.onClickRemoveFromNextUp.invoke(item)
+ },
+ )
+ }
add(
DialogItem(
text = if (watched) R.string.mark_unwatched else R.string.mark_watched,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt
index 5a01d8a3..a6a77e23 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt
@@ -73,6 +73,7 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.DataLoadingState
+import com.github.damontecres.wholphin.util.DiscoverRequestType
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
@@ -386,6 +387,7 @@ fun PersonPageContent(
DiscoverRowData(
stringResource(R.string.discover),
DataLoadingState.Success(discovered),
+ DiscoverRequestType.UNKNOWN,
),
onClickItem = { index: Int, item: DiscoverItem ->
position = RowColumn(DISCOVER_ROW, index)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt
index 57c8c5d6..ff5809fb 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt
@@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.ui.detail
+import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.focusable
@@ -16,11 +17,11 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
@@ -44,8 +45,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.DialogProperties
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
-import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme
@@ -60,12 +61,18 @@ import com.github.damontecres.wholphin.data.filter.ItemFilterBy
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
+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.MusicServiceState
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.DefaultItemFields
-import com.github.damontecres.wholphin.ui.TimeFormatter
import com.github.damontecres.wholphin.ui.cards.ItemCardImage
-import com.github.damontecres.wholphin.ui.components.DialogItem
+import com.github.damontecres.wholphin.ui.components.BasicDialog
+import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage
@@ -73,156 +80,232 @@ import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
import com.github.damontecres.wholphin.ui.components.FilterByButton
import com.github.damontecres.wholphin.ui.components.LoadingPage
+import com.github.damontecres.wholphin.ui.components.Optional
import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.SortByButton
+import com.github.damontecres.wholphin.ui.components.TextButton
+import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions
import com.github.damontecres.wholphin.ui.data.SortAndDirection
+import com.github.damontecres.wholphin.ui.detail.music.MusicMoreDialogActions
+import com.github.damontecres.wholphin.ui.detail.music.MusicQueueMarker
+import com.github.damontecres.wholphin.ui.detail.music.MusicViewModel
+import com.github.damontecres.wholphin.ui.detail.music.buildMoreDialogForMusic
import com.github.damontecres.wholphin.ui.enableMarquee
+import com.github.damontecres.wholphin.ui.equalsNotNull
import com.github.damontecres.wholphin.ui.formatDateTime
+import com.github.damontecres.wholphin.ui.getTimeFormatter
import com.github.damontecres.wholphin.ui.ifElse
+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.roundMinutes
-import com.github.damontecres.wholphin.ui.setValueOnMain
+import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.FilterUtils
import com.github.damontecres.wholphin.ui.util.LocalClock
import com.github.damontecres.wholphin.util.ApiRequestPager
+import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
-import com.github.damontecres.wholphin.util.LoadingExceptionHandler
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.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
+import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemSortBy
+import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber
import java.util.UUID
-import javax.inject.Inject
import kotlin.time.Duration
-@HiltViewModel
+@HiltViewModel(assistedFactory = PlaylistViewModel.Factory::class)
class PlaylistViewModel
- @Inject
+ @AssistedInject
constructor(
+ @ApplicationContext context: Context,
api: ApiClient,
- val navigationManager: NavigationManager,
+ navigationManager: NavigationManager,
+ musicService: MusicService,
+ mediaManagementService: MediaManagementService,
private val backdropService: BackdropService,
private val serverRepository: ServerRepository,
private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
- ) : ItemViewModel(api) {
- val loading = MutableLiveData(LoadingState.Pending)
- val items = MutableLiveData>(listOf())
- val filterAndSort =
- MutableStateFlow(
- FilterAndSort(
- filter = GetItemsFilter(),
- sortAndDirection =
- SortAndDirection(
+ private val favoriteWatchManager: FavoriteWatchManager,
+ private val mediaReportService: MediaReportService,
+ @Assisted itemId: UUID,
+ ) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) {
+ @AssistedFactory
+ interface Factory {
+ fun create(itemId: UUID): PlaylistViewModel
+ }
+
+ val state = MutableStateFlow(PlaylistDetailsState())
+ val musicState = musicService.state
+
+ init {
+ init()
+ }
+
+ override fun init() {
+ state.update { it.copy(loading = LoadingState.Loading) }
+ viewModelScope.launchDefault {
+ try {
+ val playlist =
+ api.userLibraryApi
+ .getItem(itemId)
+ .content
+ .let { BaseItem(it, false) }
+ state.update { it.copy(playlist = playlist) }
+ val libraryDisplayInfo =
+ serverRepository.currentUser.value?.let { user ->
+ libraryDisplayInfoDao.getItem(user, itemId)
+ }
+ val filter = libraryDisplayInfo?.filter ?: GetItemsFilter()
+ val sortAndDirection =
+ libraryDisplayInfo?.sortAndDirection ?: SortAndDirection(
ItemSortBy.DEFAULT,
SortOrder.ASCENDING,
- ),
- ),
- )
-
- fun init(playlistId: UUID) {
- loading.value = LoadingState.Loading
- viewModelScope.launch(
- Dispatchers.IO +
- LoadingExceptionHandler(loading, "Failed to fetch playlist $playlistId"),
- ) {
- val playlist = fetchItem(playlistId)
- val libraryDisplayInfo =
- serverRepository.currentUser.value?.let { user ->
- libraryDisplayInfoDao.getItem(user, itemId)
- }
- val filter = libraryDisplayInfo?.filter ?: GetItemsFilter()
- val sortAndDirection =
- libraryDisplayInfo?.sortAndDirection ?: SortAndDirection(
- ItemSortBy.DEFAULT,
- SortOrder.ASCENDING,
- )
- loadItems(filter, sortAndDirection)
+ )
+ loadItems(filter, sortAndDirection).join()
+ determineMediaType()
+ } catch (ex: Exception) {
+ Timber.e(ex, "Error fetching playlist %s", itemId)
+ state.update { it.copy(loading = LoadingState.Error(ex)) }
+ }
}
}
fun loadItems(
filter: GetItemsFilter,
sortAndDirection: SortAndDirection,
- ) {
- viewModelScope.launchIO {
- backdropService.clearBackdrop()
- loading.setValueOnMain(LoadingState.Loading)
- this@PlaylistViewModel.filterAndSort.update {
- FilterAndSort(filter, sortAndDirection)
- }
+ ) = viewModelScope.launchIO {
+ backdropService.clearBackdrop()
+ state.update {
+ it.copy(
+ loading = LoadingState.Loading,
+ filterAndSort = FilterAndSort(filter, sortAndDirection),
+ )
+ }
- serverRepository.currentUser.value?.let { user ->
- val playlistId = item.value!!.id
- viewModelScope.launchIO {
- val libraryDisplayInfo =
- libraryDisplayInfoDao.getItem(user, itemId)?.copy(
- filter = filter,
+ serverRepository.currentUser.value?.let { user ->
+ viewModelScope.launchIO {
+ val libraryDisplayInfo =
+ libraryDisplayInfoDao.getItem(user, itemId)?.copy(
+ filter = filter,
+ sort = sortAndDirection.sort,
+ direction = sortAndDirection.direction,
+ )
+ ?: LibraryDisplayInfo(
+ userId = user.rowId,
+ itemId = itemId.toServerString(),
sort = sortAndDirection.sort,
direction = sortAndDirection.direction,
+ filter = filter,
+ viewOptions = null,
)
- ?: LibraryDisplayInfo(
- userId = user.rowId,
- itemId = itemId,
- sort = sortAndDirection.sort,
- direction = sortAndDirection.direction,
- filter = filter,
- viewOptions = null,
- )
- libraryDisplayInfoDao.saveItem(libraryDisplayInfo)
- }
+ libraryDisplayInfoDao.saveItem(libraryDisplayInfo)
+ }
- val request =
- filter.applyTo(
- GetItemsRequest(
- parentId = playlistId,
- userId = user.id,
- fields = DefaultItemFields,
- sortBy = listOf(sortAndDirection.sort),
- sortOrder = listOf(sortAndDirection.direction),
- ),
+ val request =
+ filter.applyTo(
+ GetItemsRequest(
+ parentId = itemId,
+ userId = user.id,
+ fields = DefaultItemFields,
+ sortBy = listOf(sortAndDirection.sort),
+ sortOrder = listOf(sortAndDirection.direction),
+ ),
+ )
+ try {
+ val pager =
+ ApiRequestPager(
+ api,
+ request,
+ GetItemsRequestHandler,
+ viewModelScope,
+ ).init()
+ state.update {
+ it.copy(
+ items = pager,
+ loading = LoadingState.Success,
+ )
+ }
+ } catch (ex: Exception) {
+ Timber.e(ex, "Error fetching playlist %s", itemId)
+ state.update {
+ it.copy(
+ items = emptyList(),
+ loading = LoadingState.Error(ex),
)
- try {
- val pager =
- ApiRequestPager(
- api,
- request,
- GetItemsRequestHandler,
- viewModelScope,
- ).init()
-
- withContext(Dispatchers.Main) {
- items.value = pager
- loading.value = LoadingState.Success
- }
- } catch (ex: Exception) {
- Timber.e(ex, "Error fetching playlist %s", itemId)
- withContext(Dispatchers.Main) {
- items.value = listOf()
- loading.value = LoadingState.Error(ex)
- }
}
}
}
}
+ /**
+ * This method tries to determine the [MediaType] of a playlist
+ *
+ * In theory, the server will set the type, but sometimes it doesn't
+ */
+ private suspend fun determineMediaType() {
+ // Use the type the server says
+ var mediaType =
+ state.value.playlist
+ ?.data
+ ?.mediaType ?: MediaType.UNKNOWN
+ mediaType =
+ if (mediaType == MediaType.UNKNOWN) {
+ // Otherwise, if a most of the list is one type, we can assume that type
+ val pager = (state.value.items as? ApiRequestPager<*>)
+ if (pager != null && pager.size <= 50) {
+ val types =
+ (0..<50.coerceAtMost(pager.size)).groupBy { index ->
+ val pagerItem = pager.getBlocking(index)
+ when (pagerItem?.type) {
+ BaseItemKind.AUDIO -> MediaType.AUDIO
+
+ BaseItemKind.VIDEO,
+ BaseItemKind.EPISODE,
+ BaseItemKind.MOVIE,
+ BaseItemKind.BOX_SET,
+ -> MediaType.VIDEO
+
+ else -> MediaType.UNKNOWN
+ }
+ }
+ if (types.keys.size == 1) {
+ types.keys.first()
+ } else {
+ MediaType.UNKNOWN
+ }
+ } else {
+ MediaType.UNKNOWN
+ }
+ } else {
+ mediaType
+ }
+ Timber.d("mediaType=%s", mediaType)
+ state.update {
+ it.copy(mediaType = mediaType)
+ }
+ }
+
suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List =
FilterUtils.getFilterOptionValues(
api,
serverRepository.currentUser.value?.id,
- itemUuid,
+ itemId,
filterOption,
)
@@ -231,6 +314,24 @@ class PlaylistViewModel
backdropService.submit(item)
}
}
+
+ fun setWatched(
+ itemId: UUID,
+ played: Boolean,
+ ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
+ favoriteWatchManager.setWatched(itemId, played)
+ }
+
+ fun setFavorite(
+ itemId: UUID,
+ favorite: Boolean,
+ ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
+ favoriteWatchManager.setFavorite(itemId, favorite)
+ }
+
+ fun sendMediaReport(itemId: UUID) {
+ viewModelScope.launchDefault { mediaReportService.sendReportFor(itemId) }
+ }
}
@Immutable
@@ -239,52 +340,112 @@ data class FilterAndSort(
val sortAndDirection: SortAndDirection,
)
+data class PlaylistDetailsState(
+ val playlist: BaseItem? = null,
+ val mediaType: MediaType = MediaType.UNKNOWN,
+ val items: List = emptyList(),
+ val filterAndSort: FilterAndSort =
+ FilterAndSort(
+ filter = GetItemsFilter(),
+ sortAndDirection =
+ SortAndDirection(
+ ItemSortBy.DEFAULT,
+ SortOrder.ASCENDING,
+ ),
+ ),
+ val loading: LoadingState = LoadingState.Pending,
+)
+
@Composable
fun PlaylistDetails(
+ preferences: UserPreferences,
destination: Destination.MediaItem,
modifier: Modifier = Modifier,
- viewModel: PlaylistViewModel = hiltViewModel(),
+ viewModel: PlaylistViewModel =
+ hiltViewModel(
+ creationCallback = { it.create(destination.itemId) },
+ ),
+ addToPlaylistViewModel: AddPlaylistViewModel = hiltViewModel(),
) {
val context = LocalContext.current
- LaunchedEffect(Unit) {
- viewModel.init(destination.itemId)
- }
- val loading by viewModel.loading.observeAsState(LoadingState.Pending)
- val playlist by viewModel.item.observeAsState(null)
- val items by viewModel.items.observeAsState(listOf())
- val filterAndSort by viewModel.filterAndSort.collectAsState()
+ val state by viewModel.state.collectAsState()
+ val musicState by viewModel.musicState.collectAsState()
var longClickDialog by remember { mutableStateOf(null) }
+ var showConfirmTypeDialog by remember { mutableStateOf?>(null) }
+ var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) }
+ val addPlaylistState by addToPlaylistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
- val goToString = stringResource(R.string.go_to)
- val playFromHereString = stringResource(R.string.play_from_here)
+ fun play(
+ index: Int,
+ item: BaseItem,
+ shuffle: Boolean,
+ mediaTypeOverride: MediaType? = null,
+ ) {
+ when (mediaTypeOverride ?: state.mediaType) {
+ MediaType.VIDEO -> {
+ viewModel.navigationManager.navigateTo(
+ Destination.PlaybackList(
+ itemId = destination.itemId,
+ startIndex = index,
+ shuffle = shuffle,
+ filter = state.filterAndSort.filter,
+ sortAndDirection = state.filterAndSort.sortAndDirection,
+ ),
+ )
+ }
+
+ MediaType.AUDIO -> {
+ viewModel.play(item, index, shuffle)
+ }
+
+ else -> {
+ showConfirmTypeDialog = Triple(index, item, shuffle)
+ }
+ }
+ }
+ var showDeleteDialog by remember { mutableStateOf(null) }
+ val musicMoreActions =
+ MusicMoreDialogActions(
+ onNavigate = { viewModel.navigationManager.navigateTo(it) },
+ onClickPlay = { index, item -> play(index, item, false, MediaType.AUDIO) },
+ onClickPlayNext = { index, item -> viewModel.playNext(item) },
+ onClickAddToQueue = { index, item -> viewModel.addToQueue(item, Int.MAX_VALUE) },
+ onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) },
+ onClickAddPlaylist = { itemId ->
+ addToPlaylistViewModel.loadPlaylists(MediaType.AUDIO)
+ showPlaylistDialog.makePresent(itemId)
+ },
+ onClickRemoveFromQueue = {},
+ onClickDelete = { showDeleteDialog = it },
+ )
+ val moreActions =
+ MoreDialogActions(
+ navigateTo = { viewModel.navigationManager.navigateTo(it) },
+ onClickWatch = { id, watched -> viewModel.setWatched(id, watched) },
+ onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) },
+ onClickAddPlaylist = { itemId ->
+ addToPlaylistViewModel.loadPlaylists(MediaType.VIDEO)
+ showPlaylistDialog.makePresent(itemId)
+ },
+ onSendMediaInfo = viewModel::sendMediaReport,
+ onClickDelete = { showDeleteDialog = it },
+ onClickAddToQueue = { viewModel.addToQueue(it, 0) },
+ )
PlaylistDetailsContent(
- loadingState = loading,
- playlist = playlist,
- items = items,
+ loadingState = state.loading,
+ playlist = state.playlist,
+ items = state.items,
+ musicState = musicState,
onChangeBackdrop = viewModel::updateBackdrop,
- onClickIndex = { index, _ ->
- viewModel.navigationManager.navigateTo(
- Destination.PlaybackList(
- itemId = destination.itemId,
- startIndex = index,
- shuffle = false,
- filter = filterAndSort.filter,
- sortAndDirection = filterAndSort.sortAndDirection,
- ),
- )
+ onClickIndex = { index, item ->
+ play(index, item, false)
},
onClickPlay = { shuffle ->
- viewModel.navigationManager.navigateTo(
- Destination.PlaybackList(
- itemId = destination.itemId,
- startIndex = 0,
- shuffle = shuffle,
- filter = filterAndSort.filter,
- sortAndDirection = filterAndSort.sortAndDirection,
- ),
- )
+ state.playlist?.let {
+ play(0, it, shuffle)
+ }
},
onLongClickIndex = { index, item ->
longClickDialog =
@@ -292,31 +453,30 @@ fun PlaylistDetails(
fromLongClick = true,
title = item.name ?: "",
items =
- listOf(
- DialogItem(
- goToString,
- Icons.Default.ArrowForward,
- ) {
- viewModel.navigationManager.navigateTo(item.destination())
- },
- DialogItem(
- playFromHereString,
- Icons.Default.PlayArrow,
- ) {
- viewModel.navigationManager.navigateTo(
- Destination.PlaybackList(
- itemId = destination.itemId,
- startIndex = index,
- shuffle = false,
- filter = filterAndSort.filter,
- sortAndDirection = filterAndSort.sortAndDirection,
- ),
- )
- },
- ),
+ if (item.type == BaseItemKind.AUDIO) {
+ buildMoreDialogForMusic(
+ context = context,
+ actions = musicMoreActions,
+ item = item,
+ index = index,
+ canRemove = false,
+ canDelete = viewModel.canDelete(item, preferences.appPreferences),
+ )
+ } else {
+ buildMoreDialogItemsForHome(
+ context = context,
+ item = item,
+ seriesId = item.data.seriesId,
+ playbackPosition = item.playbackPosition,
+ watched = item.played,
+ favorite = item.favorite,
+ canDelete = viewModel.canDelete(item, preferences.appPreferences),
+ actions = moreActions,
+ )
+ },
)
},
- filterAndSort = filterAndSort,
+ filterAndSort = state.filterAndSort,
onFilterAndSortChange = viewModel::loadItems,
getPossibleFilterValues = viewModel::getFilterOptionValues,
modifier = modifier,
@@ -327,12 +487,46 @@ fun PlaylistDetails(
onDismissRequest = { longClickDialog = null },
)
}
+ showConfirmTypeDialog?.let { (index, item, shuffle) ->
+ ConfirmMediaTypeDialog(
+ onConfirm = { mediaType -> play(index, item, shuffle, mediaType) },
+ onCancel = { showConfirmTypeDialog = null },
+ )
+ }
+ showPlaylistDialog.compose { itemId ->
+ PlaylistDialog(
+ title = stringResource(R.string.add_to_playlist),
+ state = addPlaylistState,
+ onDismissRequest = { showPlaylistDialog.makeAbsent() },
+ onClick = {
+ addToPlaylistViewModel.addToPlaylist(it.id, itemId)
+ showPlaylistDialog.makeAbsent()
+ },
+ createEnabled = true,
+ onCreatePlaylist = {
+ addToPlaylistViewModel.createPlaylistAndAddItem(it, itemId)
+ showPlaylistDialog.makeAbsent()
+ },
+ elevation = 3.dp,
+ )
+ }
+ showDeleteDialog?.let { item ->
+ ConfirmDeleteDialog(
+ itemTitle = item.title ?: "",
+ onCancel = { showDeleteDialog = null },
+ onConfirm = {
+ viewModel.deleteItem(item)
+ showDeleteDialog = null
+ },
+ )
+ }
}
@Composable
fun PlaylistDetailsContent(
playlist: BaseItem?,
items: List,
+ musicState: MusicServiceState,
onClickIndex: (Int, BaseItem) -> Unit,
onLongClickIndex: (Int, BaseItem) -> Unit,
onClickPlay: (shuffle: Boolean) -> Unit,
@@ -449,10 +643,18 @@ fun PlaylistDetailsContent(
onLongClickIndex.invoke(index, item)
}
},
+ isPlaying =
+ equalsNotNull(
+ musicState.currentItemId,
+ item?.id,
+ ),
+ isQueued = item?.id in musicState.queuedIds,
modifier =
Modifier
- .height(80.dp)
.ifElse(
+ item?.type != BaseItemKind.AUDIO,
+ Modifier.height(80.dp),
+ ).ifElse(
index == savedIndex,
Modifier.focusRequester(focus),
).onFocusChanged {
@@ -582,6 +784,8 @@ fun PlaylistItem(
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+ isPlaying: Boolean = false,
+ isQueued: Boolean = false,
) {
val focused by interactionSource.collectIsFocusedAsState()
ListItem(
@@ -592,14 +796,12 @@ fun PlaylistItem(
headlineContent = {
Text(
text = item?.title ?: "",
- style = MaterialTheme.typography.titleLarge,
modifier = Modifier.enableMarquee(focused),
)
},
supportingContent = {
Text(
text = item?.subtitle ?: "",
- style = MaterialTheme.typography.titleSmall,
modifier = Modifier.enableMarquee(focused),
)
},
@@ -609,16 +811,18 @@ fun PlaylistItem(
val endTimeStr =
remember(item, now) {
val endTime = now.toLocalTime().plusSeconds(duration.inWholeSeconds)
- TimeFormatter.format(endTime)
+ getTimeFormatter().format(endTime)
}
Column {
Text(
text = duration.toString(),
)
- Text(
- text = stringResource(R.string.ends_at, endTimeStr),
- style = MaterialTheme.typography.bodySmall,
- )
+ if (item.type != BaseItemKind.AUDIO) {
+ Text(
+ text = stringResource(R.string.ends_at, endTimeStr),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
}
}
},
@@ -631,20 +835,71 @@ fun PlaylistItem(
text = "${index + 1}.",
style = MaterialTheme.typography.labelLarge,
)
- ItemCardImage(
- item = item,
- name = item?.name,
- showOverlay = true,
- favorite = item?.data?.userData?.isFavorite ?: false,
- watched = item?.data?.userData?.played ?: false,
- unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1,
- watchedPercent = 0.0,
- numberOfVersions = item?.data?.mediaSourceCount ?: 0,
- modifier = Modifier.width(160.dp),
- useFallbackText = false,
- )
+ if (item?.type == BaseItemKind.AUDIO) {
+ MusicQueueMarker(
+ isPlaying = isPlaying,
+ isQueued = isQueued,
+ )
+ } else {
+ ItemCardImage(
+ item = item,
+ name = item?.name,
+ showOverlay = true,
+ favorite = item?.data?.userData?.isFavorite ?: false,
+ watched = item?.data?.userData?.played ?: false,
+ unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1,
+ watchedPercent = 0.0,
+ numberOfVersions = item?.data?.mediaSourceCount ?: 0,
+ modifier = Modifier.width(160.dp),
+ useFallbackText = false,
+ )
+ }
}
},
modifier = modifier,
)
}
+
+@Composable
+fun ConfirmMediaTypeDialog(
+ onConfirm: (MediaType) -> Unit,
+ onCancel: () -> Unit,
+) {
+ BasicDialog(
+ onDismissRequest = onCancel,
+ properties = DialogProperties(),
+ elevation = 3.dp,
+ ) {
+ LazyColumn(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ contentPadding = PaddingValues(16.dp),
+ modifier = Modifier.wrapContentSize(),
+ ) {
+ item {
+ Text(
+ text = stringResource(R.string.play_as_type),
+ color = MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.titleLarge,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillParentMaxWidth(),
+ )
+ }
+
+ item {
+ Row(
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ TextButton(
+ stringRes = R.string.audio,
+ onClick = { onConfirm.invoke(MediaType.AUDIO) },
+ )
+ TextButton(
+ stringRes = R.string.video,
+ onClick = { onConfirm.invoke(MediaType.VIDEO) },
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionButtons.kt
new file mode 100644
index 00000000..b10e9a17
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionButtons.kt
@@ -0,0 +1,148 @@
+package com.github.damontecres.wholphin.ui.detail.collection
+
+import androidx.compose.foundation.focusGroup
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.focusRestorer
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions
+import com.github.damontecres.wholphin.data.filter.FilterValueOption
+import com.github.damontecres.wholphin.data.filter.ItemFilterBy
+import com.github.damontecres.wholphin.data.model.GetItemsFilter
+import com.github.damontecres.wholphin.ui.components.DeleteButton
+import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
+import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
+import com.github.damontecres.wholphin.ui.components.FilterByButton
+import com.github.damontecres.wholphin.ui.components.SortByButton
+import com.github.damontecres.wholphin.ui.data.MovieSortOptions
+import com.github.damontecres.wholphin.ui.data.SortAndDirection
+import kotlin.time.Duration
+
+@Composable
+fun CollectionButtons(
+ state: CollectionState,
+ onSortChange: (SortAndDirection) -> Unit,
+ onClickPlayAll: (Boolean) -> Unit,
+ onFilterChange: (GetItemsFilter) -> Unit,
+ getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List,
+ onClickViewOptions: () -> Unit,
+ favoriteOnClick: () -> Unit,
+ deleteOnClick: () -> Unit,
+ canDelete: Boolean,
+ moreOnClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val sortOptions = MovieSortOptions
+ val filterOptions = DefaultFilterOptions
+ val firstFocus = remember { FocusRequester() }
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ modifier = modifier,
+ ) {
+ LazyRow(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ contentPadding = PaddingValues(8.dp),
+ modifier =
+ Modifier
+ .focusGroup()
+ .focusRestorer(firstFocus),
+ ) {
+ item {
+ ExpandablePlayButton(
+ title = R.string.play,
+ resume = Duration.ZERO,
+ icon = Icons.Default.PlayArrow,
+ onClick = { onClickPlayAll.invoke(false) },
+ modifier = Modifier.focusRequester(firstFocus),
+ )
+ }
+ item {
+ ExpandableFaButton(
+ title = R.string.shuffle,
+ iconStringRes = R.string.fa_shuffle,
+ onClick = { onClickPlayAll.invoke(true) },
+ )
+ }
+
+ item("favorite") {
+ val favorite = remember(state.collection) { state.collection?.favorite == true }
+ ExpandableFaButton(
+ title = if (favorite) R.string.remove_favorite else R.string.add_favorite,
+ iconStringRes = R.string.fa_heart,
+ onClick = favoriteOnClick,
+ iconColor = if (favorite) Color.Red else Color.Unspecified,
+ modifier = Modifier,
+ )
+ }
+ if (canDelete) {
+ item("delete") {
+ DeleteButton(
+ onClick = deleteOnClick,
+ modifier =
+ Modifier,
+ )
+ }
+ }
+
+ item {
+ ExpandableFaButton(
+ title = R.string.view_options,
+ iconStringRes = R.string.fa_sliders,
+ onClick = onClickViewOptions,
+ modifier = Modifier,
+ )
+ }
+
+ // More button
+ item("more") {
+ ExpandablePlayButton(
+ title = R.string.more,
+ resume = Duration.ZERO,
+ icon = Icons.Default.MoreVert,
+ onClick = { moreOnClick.invoke() },
+ modifier = Modifier,
+ )
+ }
+ }
+ LazyRow(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ contentPadding = PaddingValues(8.dp),
+ modifier =
+ Modifier
+ .focusGroup(),
+ ) {
+ item {
+ SortByButton(
+ sortOptions = sortOptions,
+ current = state.sortAndDirection,
+ onSortChange = onSortChange,
+ modifier = Modifier,
+ )
+ }
+ item {
+ FilterByButton(
+ filterOptions = filterOptions,
+ current = state.itemFilter,
+ onFilterChange = onFilterChange,
+ getPossibleValues = getPossibleFilterValues,
+ modifier = Modifier,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt
new file mode 100644
index 00000000..7e0ca325
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt
@@ -0,0 +1,550 @@
+package com.github.damontecres.wholphin.ui.detail.collection
+
+import android.content.Context
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.SharedTransitionLayout
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.relocation.BringIntoViewRequester
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusProperties
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.data.filter.FilterValueOption
+import com.github.damontecres.wholphin.data.filter.ItemFilterBy
+import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.data.model.GetItemsFilter
+import com.github.damontecres.wholphin.preferences.UserPreferences
+import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
+import com.github.damontecres.wholphin.ui.components.DialogItem
+import com.github.damontecres.wholphin.ui.components.DialogParams
+import com.github.damontecres.wholphin.ui.components.DialogPopup
+import com.github.damontecres.wholphin.ui.components.ErrorMessage
+import com.github.damontecres.wholphin.ui.components.HeaderUtils
+import com.github.damontecres.wholphin.ui.components.LoadingPage
+import com.github.damontecres.wholphin.ui.components.Optional
+import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
+import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
+import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
+import com.github.damontecres.wholphin.ui.data.RowColumn
+import com.github.damontecres.wholphin.ui.data.SortAndDirection
+import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
+import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
+import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
+import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
+import com.github.damontecres.wholphin.ui.main.HomePageHeader
+import com.github.damontecres.wholphin.ui.nav.Destination
+import com.github.damontecres.wholphin.ui.tryRequestFocus
+import com.github.damontecres.wholphin.util.HomeRowLoadingState
+import com.github.damontecres.wholphin.util.LoadingState
+import org.jellyfin.sdk.model.api.MediaType
+import timber.log.Timber
+import java.util.UUID
+
+@Composable
+fun CollectionDetails(
+ preferences: UserPreferences,
+ itemId: UUID,
+ modifier: Modifier = Modifier,
+ viewModel: CollectionViewModel =
+ hiltViewModel(
+ creationCallback = { it.create(itemId) },
+ ),
+ playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
+) {
+ val context = LocalContext.current
+ val state by viewModel.state.collectAsState()
+
+ // Dialogs
+ var moreDialog by remember { mutableStateOf(null) }
+ var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) }
+ val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
+ var showDeleteDialog by remember { mutableStateOf?>(null) }
+ var showViewOptionsDialog by remember { mutableStateOf(false) }
+ var overviewDialog by remember { mutableStateOf(null) }
+
+ // Actions
+ val onClickItem =
+ remember {
+ { _: RowColumn, item: BaseItem -> viewModel.navigate(item.destination()) }
+ }
+ val onLongClickItem =
+ remember {
+ { position: RowColumn, item: BaseItem ->
+ val dialogItems =
+ buildMoreDialogItemsForHome(
+ context = context,
+ item = item,
+ seriesId = item.data.seriesId,
+ playbackPosition = item.playbackPosition,
+ watched = item.played,
+ favorite = item.favorite,
+ canDelete = viewModel.canDelete(item, preferences.appPreferences),
+ actions =
+ MoreDialogActions(
+ navigateTo = viewModel::navigate,
+ onClickWatch = { itemId, watched ->
+ viewModel.setWatched(itemId, watched, position)
+ },
+ onClickFavorite = { itemId, favorite ->
+ viewModel.setFavorite(itemId, favorite, position)
+ },
+ onClickAddPlaylist = { itemId ->
+ playlistViewModel.loadPlaylists(MediaType.VIDEO)
+ showPlaylistDialog.makePresent(itemId)
+ },
+ onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
+ onClickDelete = { item -> showDeleteDialog = Pair(position, item) },
+ onClickAddToQueue = { viewModel.addToQueue(it, 0) },
+ ),
+ )
+ moreDialog =
+ DialogParams(
+ fromLongClick = true,
+ title = item.title ?: "",
+ items = dialogItems,
+ )
+ }
+ }
+ val onSortChange =
+ remember {
+ { sort: SortAndDirection -> viewModel.changeSort(sort) }
+ }
+ val onFilterChange =
+ remember {
+ { filter: GetItemsFilter -> viewModel.changeFilter(filter) }
+ }
+ val onClickPlay = { _: RowColumn, item: BaseItem ->
+ viewModel.navigate(Destination.Playback(item = item))
+ }
+ val onClickPlayAll =
+ remember {
+ { shuffle: Boolean ->
+ val dest =
+ Destination.PlaybackList(
+ itemId = itemId,
+ startIndex = 0,
+ shuffle = shuffle,
+ recursive = true,
+ sortAndDirection = state.sortAndDirection,
+ filter = state.itemFilter,
+ )
+ viewModel.navigate(dest)
+ }
+ }
+ val onClickViewOptions = remember { { showViewOptionsDialog = true } }
+
+ when (val s = state.loadingState) {
+ is LoadingState.Error -> {
+ ErrorMessage(s, modifier)
+ }
+
+ LoadingState.Loading,
+ LoadingState.Pending,
+ -> {
+ LoadingPage(modifier)
+ }
+
+ LoadingState.Success -> {
+ CollectionDetailsContent(
+ preferences = preferences,
+ state = state,
+ onClickItem = onClickItem,
+ onLongClickItem = onLongClickItem,
+ onSortChange = onSortChange,
+ onClickPlay = onClickPlay,
+ onClickPlayAll = onClickPlayAll,
+ onChangeBackdrop = viewModel::updateBackdrop,
+ onFilterChange = onFilterChange,
+ getPossibleFilterValues = viewModel::getPossibleFilterValues,
+ letterPosition = viewModel::letterPosition,
+ onClickViewOptions = onClickViewOptions,
+ modifier = modifier,
+ overviewOnClick = {
+ val collection = state.collection!!
+ overviewDialog = ItemDetailsDialogInfo(collection)
+ },
+ favoriteOnClick =
+ remember {
+ {
+ state.collection?.let {
+ viewModel.setFavorite(it.id, !it.favorite, null)
+ }
+ }
+ },
+ deleteOnClick =
+ remember {
+ {
+ state.collection?.let {
+ viewModel.deleteItem(it, null)
+ }
+ }
+ },
+ canDelete =
+ remember(state.collection) {
+ state.collection?.let {
+ viewModel.canDelete(it, preferences.appPreferences)
+ } ?: false
+ },
+ moreOnClick = {
+ val collection = state.collection!!
+ val items =
+ buildMoreDialogItemsForCollection(
+ context = context,
+ item = collection,
+ favorite = collection.favorite,
+ canDelete = viewModel.canDelete(collection, preferences.appPreferences),
+ onClickPlayAll = onClickPlayAll,
+ actions =
+ MoreDialogActions(
+ navigateTo = viewModel::navigate,
+ onClickWatch = { itemId, watched ->
+ viewModel.setWatched(itemId, watched, null)
+ },
+ onClickFavorite = { itemId, favorite ->
+ viewModel.setFavorite(itemId, favorite, null)
+ },
+ onClickAddPlaylist = { itemId ->
+ playlistViewModel.loadPlaylists(MediaType.VIDEO)
+ showPlaylistDialog.makePresent(itemId)
+ },
+ onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
+ onClickDelete = { item -> showDeleteDialog = Pair(null, item) },
+ onClickAddToQueue = { viewModel.addToQueue(it, 0) },
+ ),
+ )
+ moreDialog =
+ DialogParams(
+ fromLongClick = false,
+ title = collection.title ?: "",
+ items = items,
+ )
+ },
+ )
+ }
+ }
+ if (showViewOptionsDialog) {
+ CollectionViewOptionsDialog(
+ viewOptions = state.viewOptions,
+ onDismissRequest = { showViewOptionsDialog = false },
+ onViewOptionsChange = viewModel::changeViewOptions,
+ )
+ }
+ moreDialog?.let { params ->
+ DialogPopup(
+ showDialog = true,
+ title = params.title,
+ dialogItems = params.items,
+ onDismissRequest = { moreDialog = null },
+ dismissOnClick = true,
+ waitToLoad = params.fromLongClick,
+ )
+ }
+ showPlaylistDialog.compose { itemId ->
+ PlaylistDialog(
+ title = stringResource(R.string.add_to_playlist),
+ state = playlistState,
+ onDismissRequest = { showPlaylistDialog.makeAbsent() },
+ onClick = {
+ playlistViewModel.addToPlaylist(it.id, itemId)
+ showPlaylistDialog.makeAbsent()
+ },
+ createEnabled = true,
+ onCreatePlaylist = {
+ playlistViewModel.createPlaylistAndAddItem(it, itemId)
+ showPlaylistDialog.makeAbsent()
+ },
+ elevation = 3.dp,
+ )
+ }
+ showDeleteDialog?.let { (position, item) ->
+ ConfirmDeleteDialog(
+ itemTitle = item.title ?: "",
+ onCancel = { showDeleteDialog = null },
+ onConfirm = {
+ viewModel.deleteItem(item, position)
+ showDeleteDialog = null
+ },
+ )
+ }
+ overviewDialog?.let { info ->
+ ItemDetailsDialog(
+ info = info,
+ showFilePath =
+ viewModel.serverRepository.currentUserDto.value
+ ?.policy
+ ?.isAdministrator == true,
+ onDismissRequest = { overviewDialog = null },
+ )
+ }
+}
+
+@Composable
+fun CollectionDetailsContent(
+ preferences: UserPreferences,
+ state: CollectionState,
+ onClickItem: (RowColumn, BaseItem) -> Unit,
+ onLongClickItem: (RowColumn, BaseItem) -> Unit,
+ onSortChange: (SortAndDirection) -> Unit,
+ onClickPlay: (RowColumn, BaseItem) -> Unit,
+ onClickPlayAll: (Boolean) -> Unit,
+ onChangeBackdrop: (BaseItem) -> Unit,
+ onFilterChange: (GetItemsFilter) -> Unit,
+ getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List,
+ letterPosition: suspend (Char) -> Int,
+ onClickViewOptions: () -> Unit,
+ overviewOnClick: () -> Unit,
+ favoriteOnClick: () -> Unit,
+ deleteOnClick: () -> Unit,
+ canDelete: Boolean,
+ moreOnClick: () -> Unit,
+ modifier: Modifier,
+) {
+ var itemsContentHasFocus by rememberSaveable { mutableStateOf(false) }
+ val bringIntoViewRequester = remember { BringIntoViewRequester() }
+ val focusRequester = remember { FocusRequester() }
+ val contentFocusRequester = remember { FocusRequester() }
+
+ var focusedItem by remember { mutableStateOf(state.collection) }
+ LaunchedEffect(focusedItem) {
+ focusedItem?.let { onChangeBackdrop.invoke(it) }
+ }
+
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = modifier,
+ ) {
+ SharedTransitionLayout {
+ AnimatedContent(
+ targetState = itemsContentHasFocus,
+ label = "header_transition",
+ ) { targetState ->
+ if (targetState) {
+ // Show item header
+ LaunchedEffect(Unit) {
+ contentFocusRequester.tryRequestFocus()
+ }
+ Column(
+ Modifier.sharedBounds(
+ rememberSharedContentState(key = "header"),
+ animatedVisibilityScope = this@AnimatedContent,
+ enter = slideInVertically { it / 2 } + fadeIn(),
+ exit = slideOutVertically { it / 2 } + fadeOut(),
+ ),
+ ) {
+ // This box exists so that there is something focusable above the item content
+ // allowing focus to move up to restore the collection's header
+ Box(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(0.dp)
+ .onFocusChanged {
+ if (it.isFocused) itemsContentHasFocus = false
+ }.focusable(),
+ )
+ if (state.viewOptions.cardViewOptions.showDetails) {
+ HomePageHeader(
+ item = focusedItem,
+ showLogo = preferences.appPreferences.interfacePreferences.showLogos,
+ modifier =
+ Modifier
+ .padding(
+ top = HeaderUtils.topPadding,
+ bottom = 8.dp,
+ ).height(HeaderUtils.height),
+ )
+ }
+ }
+ } else {
+ // Show collection header
+ LaunchedEffect(Unit) {
+ focusRequester.tryRequestFocus()
+ focusedItem = state.collection
+ }
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier =
+ Modifier
+ .sharedBounds(
+ rememberSharedContentState(key = "header"),
+ animatedVisibilityScope = this@AnimatedContent,
+ enter = slideInVertically { -it / 2 } + fadeIn(),
+ exit = slideOutVertically { -it / 2 } + fadeOut(),
+ ).padding(bottom = 16.dp)
+ .fillMaxWidth()
+ .onFocusChanged {
+ if (it.hasFocus) {
+ onChangeBackdrop.invoke(state.collection!!)
+ }
+ },
+ ) {
+ CollectionDetailsHeader(
+ collection = state.collection!!,
+ showLogo = preferences.appPreferences.interfacePreferences.showLogos,
+ logoImageUrl = state.logoImageUrl,
+ overviewOnClick = overviewOnClick,
+ bringIntoViewRequester = bringIntoViewRequester,
+ modifier =
+ Modifier
+ .padding(
+ top = HeaderUtils.topPadding,
+ bottom = HeaderUtils.bottomPadding,
+ ).height(HeaderUtils.height),
+ )
+ CollectionButtons(
+ state = state,
+ onSortChange = onSortChange,
+ onClickPlayAll = onClickPlayAll,
+ onFilterChange = onFilterChange,
+ getPossibleFilterValues = getPossibleFilterValues,
+ onClickViewOptions = onClickViewOptions,
+ favoriteOnClick = favoriteOnClick,
+ deleteOnClick = deleteOnClick,
+ canDelete = canDelete,
+ moreOnClick = moreOnClick,
+ modifier =
+ Modifier
+ .focusRequester(focusRequester)
+ .fillMaxWidth(),
+ )
+ }
+ }
+ }
+ }
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .onFocusChanged {
+ if (it.hasFocus) itemsContentHasFocus = true
+ }.focusProperties {
+ up = focusRequester
+ }.focusRequester(contentFocusRequester),
+ ) {
+ if (state.viewOptions.separateTypes) {
+ CollectionRows(
+ preferences = preferences,
+ state = state,
+ onClickItem = onClickItem,
+ onLongClickItem = onLongClickItem,
+ onClickPlay = onClickPlay,
+ modifier = Modifier.fillMaxSize(),
+ onFocusPosition = { position ->
+ Timber.v("onFocusPosition=%s", position)
+ focusedItem =
+ position.let {
+ val key =
+ state.separateItems.keys
+ .toList()
+ .getOrNull(it.row)
+ (state.separateItems[key] as? HomeRowLoadingState.Success)?.items?.getOrNull(
+ it.column,
+ )
+ }
+ },
+ )
+ } else {
+ CollectionMixedGrid(
+ preferences = preferences,
+ state = state,
+ onClickItem = onClickItem,
+ onLongClickItem = onLongClickItem,
+ onClickPlay = onClickPlay,
+ letterPosition = letterPosition,
+ modifier = Modifier.fillMaxSize(),
+ onFocusPosition = {
+ Timber.v("onFocusPosition=%s", it)
+ focusedItem = state.items.getOrNull(it.column)
+ },
+ )
+ }
+ }
+ }
+}
+
+fun buildMoreDialogItemsForCollection(
+ context: Context,
+ item: BaseItem,
+ favorite: Boolean,
+ canDelete: Boolean,
+ onClickPlayAll: (shuffle: Boolean) -> Unit,
+ actions: MoreDialogActions,
+): List =
+ buildList {
+ add(
+ DialogItem(
+ context.getString(R.string.play),
+ Icons.Default.PlayArrow,
+ iconColor = Color.Green.copy(alpha = .8f),
+ ) {
+ onClickPlayAll.invoke(false)
+ },
+ )
+ add(
+ DialogItem(
+ context.getString(R.string.shuffle),
+ R.string.fa_shuffle,
+ ) {
+ onClickPlayAll.invoke(true)
+ },
+ )
+
+ add(
+ DialogItem(
+ text = R.string.add_to_playlist,
+ iconStringRes = R.string.fa_list_ul,
+ ) {
+ actions.onClickAddPlaylist.invoke(item.id)
+ },
+ )
+ if (canDelete) {
+ add(
+ DialogItem(
+ context.getString(R.string.delete),
+ Icons.Default.Delete,
+ iconColor = Color.Red.copy(alpha = .8f),
+ ) {
+ actions.onClickDelete.invoke(item)
+ },
+ )
+ }
+ add(
+ DialogItem(
+ text = if (favorite) R.string.remove_favorite else R.string.add_favorite,
+ iconStringRes = R.string.fa_heart,
+ iconColor = if (favorite) Color.Red else Color.Unspecified,
+ ) {
+ actions.onClickFavorite.invoke(item.id, !favorite)
+ },
+ )
+ }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetailsHeader.kt
new file mode 100644
index 00000000..cbdf1560
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetailsHeader.kt
@@ -0,0 +1,95 @@
+package com.github.damontecres.wholphin.ui.detail.collection
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.relocation.BringIntoViewRequester
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.ui.components.GenreText
+import com.github.damontecres.wholphin.ui.components.HeaderUtils
+import com.github.damontecres.wholphin.ui.components.OverviewText
+import com.github.damontecres.wholphin.ui.components.QuickDetails
+import com.github.damontecres.wholphin.ui.components.TitleOrLogo
+import com.github.damontecres.wholphin.ui.letNotEmpty
+import com.github.damontecres.wholphin.util.ExceptionHandler
+import kotlinx.coroutines.launch
+
+@Composable
+fun CollectionDetailsHeader(
+ collection: BaseItem,
+ showLogo: Boolean,
+ logoImageUrl: String?,
+ overviewOnClick: () -> Unit,
+ bringIntoViewRequester: BringIntoViewRequester,
+ modifier: Modifier = Modifier,
+) {
+ val dto = collection.data
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ Column(
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ modifier = modifier,
+ ) {
+ TitleOrLogo(
+ title = collection.name,
+ showLogo = showLogo,
+ logoImageUrl = logoImageUrl,
+ modifier =
+ Modifier
+ .fillMaxWidth(.75f)
+ .padding(start = HeaderUtils.startPadding),
+ )
+
+ Column(
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ modifier = Modifier.fillMaxWidth(.60f),
+ ) {
+ QuickDetails(
+ collection.ui.quickDetails,
+ collection.timeRemainingOrRuntime,
+ Modifier.padding(start = HeaderUtils.startPadding),
+ )
+
+ dto.genres?.letNotEmpty {
+ GenreText(it, Modifier.padding(start = HeaderUtils.startPadding))
+ }
+ dto.taglines?.firstOrNull()?.let { tagline ->
+ Text(
+ text = tagline,
+ style = MaterialTheme.typography.bodyLarge,
+ fontStyle = FontStyle.Italic,
+ modifier = Modifier.padding(start = HeaderUtils.startPadding),
+ )
+ }
+
+ // Description
+ dto.overview?.let { overview ->
+ OverviewText(
+ overview = overview,
+ maxLines = 3,
+ onClick = overviewOnClick,
+ textBoxHeight = Dp.Unspecified,
+ modifier =
+ Modifier.onFocusChanged {
+ if (it.isFocused) {
+ scope.launch(ExceptionHandler()) {
+ bringIntoViewRequester.bringIntoView()
+ }
+ }
+ },
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionMixedGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionMixedGrid.kt
new file mode 100644
index 00000000..64a6eb10
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionMixedGrid.kt
@@ -0,0 +1,89 @@
+package com.github.damontecres.wholphin.ui.detail.collection
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.dp
+import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.preferences.UserPreferences
+import com.github.damontecres.wholphin.ui.cards.GridCard
+import com.github.damontecres.wholphin.ui.data.RowColumn
+import com.github.damontecres.wholphin.ui.detail.CardGrid
+import com.github.damontecres.wholphin.ui.playback.scale
+import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
+import org.jellyfin.sdk.model.api.ItemSortBy
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun CollectionMixedGrid(
+ preferences: UserPreferences,
+ state: CollectionState,
+ onClickItem: (RowColumn, BaseItem) -> Unit,
+ onLongClickItem: (RowColumn, BaseItem) -> Unit,
+ onClickPlay: (RowColumn, BaseItem) -> Unit,
+ letterPosition: suspend (Char) -> Int,
+ modifier: Modifier = Modifier,
+ onFocusPosition: (RowColumn) -> Unit = {},
+) {
+ val gridFocusRequester = remember { FocusRequester() }
+
+ Box(modifier = modifier) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(0.dp),
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current
+ val density = LocalDensity.current
+
+ val cardViewOptions = state.viewOptions.cardViewOptions
+ CardGrid(
+ pager = state.items,
+ onClickItem = { index: Int, item: BaseItem -> onClickItem.invoke(RowColumn(0, index), item) },
+ onLongClickItem = { index: Int, item: BaseItem -> onLongClickItem.invoke(RowColumn(0, index), item) },
+ onClickPlay = { index: Int, item: BaseItem -> onClickPlay.invoke(RowColumn(0, index), item) },
+ letterPosition = letterPosition,
+ gridFocusRequester = gridFocusRequester,
+ showJumpButtons = false, // TODO add preference
+ showLetterButtons = state.sortAndDirection.sort == ItemSortBy.SORT_NAME,
+ modifier =
+ Modifier
+ .fillMaxSize(),
+ initialPosition = 0,
+ positionCallback = { _, newPosition ->
+ onFocusPosition.invoke(RowColumn(0, newPosition))
+ },
+ cardContent = { item, onClick, onLongClick, mod ->
+ GridCard(
+ item = item,
+ onClick = onClick,
+ onLongClick = onLongClick,
+ imageContentScale = cardViewOptions.contentScale.scale,
+ imageAspectRatio = cardViewOptions.aspectRatio.ratio,
+ imageType = cardViewOptions.imageType,
+ showTitle = cardViewOptions.showTitles,
+ modifier = mod,
+ )
+ },
+ columns = cardViewOptions.columns,
+ spacing = cardViewOptions.spacing.dp,
+ bringIntoViewSpec =
+ remember(cardViewOptions) {
+ val spacingPx = with(density) { cardViewOptions.spacing.dp.toPx() }
+ if (cardViewOptions.showDetails) {
+ ScrollToTopBringIntoViewSpec(spacingPx)
+ } else {
+ defaultBringIntoViewSpec
+ }
+ },
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionRows.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionRows.kt
new file mode 100644
index 00000000..b33e751f
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionRows.kt
@@ -0,0 +1,91 @@
+package com.github.damontecres.wholphin.ui.detail.collection
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
+import com.github.damontecres.wholphin.preferences.UserPreferences
+import com.github.damontecres.wholphin.ui.AspectRatio
+import com.github.damontecres.wholphin.ui.Cards
+import com.github.damontecres.wholphin.ui.data.RowColumn
+import com.github.damontecres.wholphin.ui.main.HomePageContent
+import com.github.damontecres.wholphin.ui.rememberPosition
+import com.github.damontecres.wholphin.util.HomeRowLoadingState
+import org.jellyfin.sdk.model.api.BaseItemKind
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun CollectionRows(
+ preferences: UserPreferences,
+ state: CollectionState,
+ onClickItem: (RowColumn, BaseItem) -> Unit,
+ onLongClickItem: (RowColumn, BaseItem) -> Unit,
+ onClickPlay: (RowColumn, BaseItem) -> Unit,
+ modifier: Modifier = Modifier,
+ onFocusPosition: (RowColumn) -> Unit = {},
+) {
+ var position by rememberPosition(0, 0)
+
+ Box(modifier = modifier) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(0.dp),
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(top = 8.dp),
+ ) {
+ val cardViewOptions = state.viewOptions.cardViewOptions
+ val homeRows =
+ remember(state.separateItems, cardViewOptions) {
+ state.separateItems.map { (type, row) ->
+ if (row is HomeRowLoadingState.Success) {
+ // TODO not great to do this in the UI
+ val viewOptions =
+ if (type == BaseItemKind.EPISODE) {
+ HomeRowViewOptions(
+ heightDp = Cards.HEIGHT_EPISODE,
+ episodeAspectRatio = AspectRatio.WIDE,
+ showTitles = cardViewOptions.showTitles,
+ useSeries = false,
+ )
+ } else {
+ HomeRowViewOptions(
+ showTitles = cardViewOptions.showTitles,
+ )
+ }
+ row.copy(viewOptions = viewOptions)
+ } else {
+ row
+ }
+ }
+ }
+ HomePageContent(
+ homeRows = homeRows,
+ position = position,
+ onFocusPosition = { newPosition ->
+ position = newPosition
+ onFocusPosition.invoke(newPosition)
+ },
+ onClickItem = onClickItem,
+ onLongClickItem = onLongClickItem,
+ onClickPlay = onClickPlay,
+ showClock = false,
+ onUpdateBackdrop = {},
+ headerComposable = {},
+ takeFocus = false,
+ showLogo = preferences.appPreferences.interfacePreferences.showLogos,
+ modifier = Modifier,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt
new file mode 100644
index 00000000..b0a6c606
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt
@@ -0,0 +1,501 @@
+package com.github.damontecres.wholphin.ui.detail.collection
+
+import android.content.Context
+import androidx.compose.runtime.Stable
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.asFlow
+import androidx.lifecycle.viewModelScope
+import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
+import com.github.damontecres.wholphin.data.ServerRepository
+import com.github.damontecres.wholphin.data.filter.FilterValueOption
+import com.github.damontecres.wholphin.data.filter.ItemFilterBy
+import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.data.model.GetItemsFilter
+import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
+import com.github.damontecres.wholphin.preferences.AppPreferences
+import com.github.damontecres.wholphin.services.BackdropService
+import com.github.damontecres.wholphin.services.FavoriteWatchManager
+import com.github.damontecres.wholphin.services.ImageUrlService
+import com.github.damontecres.wholphin.services.KeyValueService
+import com.github.damontecres.wholphin.services.MediaManagementService
+import com.github.damontecres.wholphin.services.MediaReportService
+import com.github.damontecres.wholphin.services.MusicService
+import com.github.damontecres.wholphin.services.NavigationManager
+import com.github.damontecres.wholphin.services.ThemeSongPlayer
+import com.github.damontecres.wholphin.services.UserPreferencesService
+import com.github.damontecres.wholphin.services.deleteItem
+import com.github.damontecres.wholphin.ui.SlimItemFields
+import com.github.damontecres.wholphin.ui.collectLatestIn
+import com.github.damontecres.wholphin.ui.data.RowColumn
+import com.github.damontecres.wholphin.ui.data.SortAndDirection
+import com.github.damontecres.wholphin.ui.detail.music.addToQueue
+import com.github.damontecres.wholphin.ui.formatTypeName
+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.toServerString
+import com.github.damontecres.wholphin.ui.util.FilterUtils
+import com.github.damontecres.wholphin.util.ApiRequestPager
+import com.github.damontecres.wholphin.util.ExceptionHandler
+import com.github.damontecres.wholphin.util.GetItemsRequestHandler
+import com.github.damontecres.wholphin.util.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.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.async
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.supervisorScope
+import kotlinx.coroutines.withContext
+import org.jellyfin.sdk.api.client.ApiClient
+import org.jellyfin.sdk.api.client.extensions.userLibraryApi
+import org.jellyfin.sdk.model.api.BaseItemKind
+import org.jellyfin.sdk.model.api.ImageType
+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
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@HiltViewModel(assistedFactory = CollectionViewModel.Factory::class)
+class CollectionViewModel
+ @AssistedInject
+ constructor(
+ @param:ApplicationContext private val context: Context,
+ private val api: ApiClient,
+ val serverRepository: ServerRepository,
+ private val navigationManager: NavigationManager,
+ private val preferencesService: UserPreferencesService,
+ private val themeSongPlayer: ThemeSongPlayer,
+ private val mediaManagementService: MediaManagementService,
+ private val favoriteWatchManager: FavoriteWatchManager,
+ private val backdropService: BackdropService,
+ private val keyValueService: KeyValueService,
+ private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
+ private val imageUrlService: ImageUrlService,
+ private val musicService: MusicService,
+ val mediaReportService: MediaReportService,
+ @Assisted private val itemId: UUID,
+ ) : ViewModel() {
+ @AssistedFactory
+ interface Factory {
+ fun create(itemId: UUID): CollectionViewModel
+ }
+
+ private val viewOptionsFlow =
+ serverRepository.currentUser
+ .asFlow()
+ .filterNotNull()
+ .flatMapLatest {
+ keyValueService.get(it.id, VIEW_OPTIONS_KEY, CollectionViewOptions())
+ }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), CollectionViewOptions())
+
+ private val libraryDisplayInfoFlow =
+ serverRepository.currentUser
+ .asFlow()
+ .filterNotNull()
+ .flatMapLatest {
+ libraryDisplayInfoDao.getItemAsFlow(it.rowId, itemId.toServerString())
+ }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
+
+ private val _state = MutableStateFlow(CollectionState())
+ val state: StateFlow = _state
+
+ init {
+ addCloseable { release() }
+ // Get global per-user view options for collections
+ viewOptionsFlow.collectLatestIn(viewModelScope) { viewOptions ->
+ Timber.v("Updated viewOptions")
+ _state.update {
+ it.copy(viewOptions = viewOptions)
+ }
+ }
+ libraryDisplayInfoFlow
+ .filterNotNull()
+ .collectLatestIn(viewModelScope) { libraryDisplayInfo ->
+ Timber.v("Updated libraryDisplayInfo")
+ _state.update {
+ it.copy(
+ itemFilter = libraryDisplayInfo.filter,
+ sortAndDirection = libraryDisplayInfo.sortAndDirection,
+ )
+ }
+ }
+ viewModelScope.launchDefault {
+ val collection =
+ api.userLibraryApi
+ .getItem(itemId)
+ .content
+ .let { BaseItem(it, false) }
+ backdropService.submit(collection)
+ val logoImageUrl =
+ if (ImageType.LOGO in collection.data.imageTags.orEmpty()) {
+ imageUrlService.getItemImageUrl(collection, ImageType.LOGO)
+ } else {
+ null
+ }
+ _state.update {
+ it.copy(
+ collection = collection,
+ logoImageUrl = logoImageUrl,
+ )
+ }
+ listenForStateUpdates()
+ themeSongPlayer.playThemeFor(
+ itemId,
+ preferencesService
+ .getCurrent()
+ .appPreferences.interfacePreferences.playThemeSongs,
+ )
+ }
+ }
+
+ fun release() {
+ themeSongPlayer.stop()
+ }
+
+ /**
+ * Collects on [state] and fetches data when needed
+ */
+ private fun listenForStateUpdates() =
+ viewModelScope.launchDefault {
+ state
+ .map {
+ Triple(
+ it.sortAndDirection,
+ it.itemFilter,
+ it.viewOptions.separateTypes,
+ )
+ }.distinctUntilChanged()
+ .collectLatest { (sort, filter, separateTypes) ->
+ try {
+ updateData(sort, filter, separateTypes)
+ } catch (ex: Exception) {
+ Timber.e(
+ ex,
+ "Error fetching data for collection %s",
+ itemId,
+ )
+ _state.update { it.copy(loadingState = LoadingState.Error(ex)) }
+ }
+ }
+ }
+
+ private suspend fun updateData(
+ sort: SortAndDirection,
+ filter: GetItemsFilter,
+ separateTypes: Boolean,
+ ) {
+ Timber.d("Begin updateData for %s", itemId)
+ _state.update {
+ it.copy(
+ loadingState = LoadingState.Loading,
+ items = emptyList(),
+ separateItems = emptyMap(),
+ )
+ }
+ if (!separateTypes) {
+ val result = fetchItems(sort, filter, typesInCollection)
+ _state.update { it.copy(items = result) }
+ } else {
+ supervisorScope {
+ val jobs =
+ typesInCollection.map { type ->
+ async(Dispatchers.IO) {
+ val title = context.getString(formatTypeName(type))
+ val result =
+ try {
+ val pager = fetchItems(sort, filter, listOf(type))
+ HomeRowLoadingState.Success(title, pager)
+ } catch (ex: Exception) {
+ Timber.e(
+ ex,
+ "Error fetching %s for collection %s",
+ type,
+ itemId,
+ )
+ HomeRowLoadingState.Error(title, exception = ex)
+ }
+ type to result
+ }
+ }
+ jobs.forEach { job ->
+ val (type, row) = job.await()
+ _state.update {
+ val separateItems =
+ it.separateItems.toMutableMap().apply {
+ put(type, row)
+ }
+ it.copy(separateItems = separateItems)
+ }
+ }
+ }
+ }
+ _state.update { it.copy(loadingState = LoadingState.Success) }
+ Timber.d("End updateData for %s", itemId)
+ }
+
+ private suspend fun fetchItems(
+ sort: SortAndDirection?,
+ filter: GetItemsFilter?,
+ types: List,
+ ): ApiRequestPager {
+ val request = createGetItemsRequest(sort, filter, types)
+ val useSeriesForPrimary = !state.value.viewOptions.separateTypes
+ return ApiRequestPager(
+ api,
+ request,
+ GetItemsRequestHandler,
+ viewModelScope,
+ useSeriesForPrimary = useSeriesForPrimary,
+ ).init()
+ }
+
+ private fun createGetItemsRequest(
+ sort: SortAndDirection?,
+ filter: GetItemsFilter?,
+ types: List,
+ ): GetItemsRequest {
+ val includeItemTypes: List?
+ val excludeItemTypes: List?
+ // Workaround for https://github.com/jellyfin/jellyfin/issues/16454
+ if (types.size == 1 && types.first() == BaseItemKind.BOX_SET) {
+ includeItemTypes = null
+ excludeItemTypes =
+ BaseItemKind.entries
+ .toMutableList()
+ .apply { remove(BaseItemKind.BOX_SET) }
+ } else {
+ includeItemTypes = types
+ excludeItemTypes = null
+ }
+ val request =
+ GetItemsRequest(
+ userId = serverRepository.currentUser.value?.id,
+ parentId = itemId,
+ includeItemTypes = includeItemTypes,
+ excludeItemTypes = excludeItemTypes,
+ recursive = false,
+ sortBy = sort?.let { listOf(sort.sort) },
+ sortOrder = sort?.let { listOf(sort.direction) },
+ fields = SlimItemFields,
+ ).let {
+ filter?.applyTo(it, false) ?: it
+ }
+ return request
+ }
+
+ fun changeSort(sortAndDirection: SortAndDirection) {
+ viewModelScope.launchIO {
+ val user = serverRepository.currentUser.value
+ val state = _state.value
+ if (user != null) {
+ libraryDisplayInfoDao.saveItem(
+ LibraryDisplayInfo(
+ user = user,
+ itemId = itemId,
+ sort = sortAndDirection.sort,
+ direction = sortAndDirection.direction,
+ filter = state.itemFilter,
+ viewOptions = null,
+ ),
+ )
+ }
+ }
+ }
+
+ fun changeFilter(filter: GetItemsFilter) {
+ viewModelScope.launchIO {
+ val user = serverRepository.currentUser.value
+ val state = _state.value
+ if (user != null) {
+ libraryDisplayInfoDao.saveItem(
+ LibraryDisplayInfo(
+ user = user,
+ itemId = itemId,
+ sort = state.sortAndDirection.sort,
+ direction = state.sortAndDirection.direction,
+ filter = filter,
+ viewOptions = null,
+ ),
+ )
+ }
+ }
+ }
+
+ fun changeViewOptions(viewOptions: CollectionViewOptions) {
+ viewModelScope.launchIO {
+ if (!viewOptions.cardViewOptions.showDetails) {
+ val collection = state.value.collection
+ if (collection != null) {
+ backdropService.submit(collection)
+ } else {
+ backdropService.clearBackdrop()
+ }
+ }
+ serverRepository.currentUser.value?.id?.let { userId ->
+ keyValueService.save(userId, VIEW_OPTIONS_KEY, viewOptions)
+ }
+ }
+ }
+
+ suspend fun getPossibleFilterValues(filterOption: ItemFilterBy<*>): List =
+ FilterUtils.getFilterOptionValues(
+ api,
+ serverRepository.currentUser.value?.id,
+ itemId,
+ filterOption,
+ )
+
+ suspend fun letterPosition(letter: Char): Int =
+ withContext(Dispatchers.IO) {
+ val sort = state.value.sortAndDirection
+ val filter = state.value.itemFilter
+ val request =
+ createGetItemsRequest(
+ sort = sort,
+ filter = filter,
+ types = typesInCollection,
+ ).copy(
+ enableImageTypes = null,
+ fields = null,
+ nameLessThan = letter.toString(),
+ limit = 0,
+ enableTotalRecordCount = true,
+ )
+ val result by GetItemsRequestHandler.execute(api, request)
+ result.totalRecordCount
+ }
+
+ fun navigate(destination: Destination) {
+ release()
+ navigationManager.navigateTo(destination)
+ }
+
+ fun setWatched(
+ itemId: UUID,
+ played: Boolean,
+ position: RowColumn?,
+ ) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
+ favoriteWatchManager.setWatched(itemId, played)
+ if (position != null) {
+ refreshItem(itemId, position, false)
+ }
+ }
+
+ fun setFavorite(
+ itemId: UUID,
+ favorite: Boolean,
+ position: RowColumn?,
+ ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
+ favoriteWatchManager.setFavorite(itemId, favorite)
+ if (position != null) {
+ refreshItem(itemId, position, false)
+ }
+ }
+
+ fun canDelete(
+ item: BaseItem,
+ appPreferences: AppPreferences,
+ ): Boolean = mediaManagementService.canDelete(item, appPreferences)
+
+ fun deleteItem(
+ item: BaseItem,
+ position: RowColumn?,
+ ) {
+ deleteItem(context, mediaManagementService, item) {
+ viewModelScope.launchIO {
+ if (position != null) {
+ refreshItem(itemId, position, true)
+ }
+ }
+ }
+ }
+
+ private suspend fun refreshItem(
+ itemId: UUID,
+ position: RowColumn,
+ isDelete: Boolean,
+ ) {
+ state.value.let { state ->
+ val items =
+ if (state.viewOptions.separateTypes) {
+ val key =
+ state.separateItems.keys
+ .toList()
+ .getOrNull(position.row)
+ (state.separateItems[key] as? HomeRowLoadingState.Success)?.items as? ApiRequestPager<*>
+ } else {
+ state.items as? ApiRequestPager<*>
+ }
+ if (isDelete) {
+ items?.refreshPagesAfter(position.column)
+ } else {
+ items?.refreshItem(position.column, itemId)
+ }
+ }
+ }
+
+ fun updateBackdrop(item: BaseItem) {
+ viewModelScope.launchDefault {
+ val collection = state.value.collection
+ if (item.id == collection?.id) {
+ // Always show the collection's backdrop if requested
+ backdropService.submit(collection)
+ } else if (state.value.viewOptions.cardViewOptions.showDetails) {
+ backdropService.submit(item)
+ } else {
+ backdropService.clearBackdrop()
+ }
+ }
+ }
+
+ fun addToQueue(
+ item: BaseItem,
+ index: Int,
+ ) = addToQueue(api, musicService, item, index)
+
+ companion object {
+ val typesInCollection =
+ listOf(
+ BaseItemKind.MOVIE,
+ BaseItemKind.SERIES,
+ BaseItemKind.EPISODE,
+ BaseItemKind.VIDEO,
+ BaseItemKind.BOX_SET,
+ )
+
+ const val VIEW_OPTIONS_KEY = "CollectionViewOptions"
+ }
+ }
+
+@Stable
+data class CollectionState(
+ val loadingState: LoadingState = LoadingState.Pending,
+ val collection: BaseItem? = null,
+ val sortAndDirection: SortAndDirection =
+ SortAndDirection(
+ ItemSortBy.DEFAULT,
+ SortOrder.ASCENDING,
+ ),
+ val itemFilter: GetItemsFilter = GetItemsFilter(),
+ val viewOptions: CollectionViewOptions = CollectionViewOptions(),
+ val items: List = emptyList(),
+ val separateItems: Map = emptyMap(),
+ val logoImageUrl: String? = null,
+)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewOptionsDialog.kt
new file mode 100644
index 00000000..b080e817
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewOptionsDialog.kt
@@ -0,0 +1,174 @@
+package com.github.damontecres.wholphin.ui.detail.collection
+
+import android.view.Gravity
+import androidx.compose.foundation.background
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import androidx.compose.ui.window.DialogWindowProvider
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import androidx.tv.material3.surfaceColorAtElevation
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.preferences.AppPreference
+import com.github.damontecres.wholphin.preferences.AppSwitchPreference
+import com.github.damontecres.wholphin.ui.components.ViewOptions
+import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsAspectRatio
+import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsColumns
+import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsContentScale
+import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsDetailHeader
+import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsImageType
+import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsReset
+import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsShowTitles
+import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsSpacing
+import com.github.damontecres.wholphin.ui.preferences.ComposablePreference
+import com.github.damontecres.wholphin.ui.tryRequestFocus
+import kotlinx.serialization.Serializable
+
+@Composable
+fun CollectionViewOptionsDialog(
+ viewOptions: CollectionViewOptions,
+ onDismissRequest: () -> Unit,
+ onViewOptionsChange: (CollectionViewOptions) -> Unit,
+ defaultViewOptions: CollectionViewOptions = CollectionViewOptions(),
+) {
+ val focusRequester = remember { FocusRequester() }
+ LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
+ val columnState = rememberLazyListState()
+ val options =
+ if (viewOptions.separateTypes) CollectionViewOptions.SeparateOptions else CollectionViewOptions.MixedOptions
+ Dialog(
+ onDismissRequest = onDismissRequest,
+ properties =
+ DialogProperties(
+ usePlatformDefaultWidth = false,
+ ),
+ ) {
+ val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider
+ dialogWindowProvider?.window?.let { window ->
+ window.setGravity(Gravity.END)
+ window.setDimAmount(0f)
+ }
+ Column(
+ modifier =
+ Modifier
+ .width(256.dp)
+ .heightIn(max = 380.dp)
+ .focusRequester(focusRequester)
+ .background(
+ MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp),
+ shape = RoundedCornerShape(8.dp),
+ ).padding(16.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.view_options),
+ style = MaterialTheme.typography.titleMedium,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ LazyColumn(
+ state = columnState,
+ contentPadding = PaddingValues(0.dp),
+ verticalArrangement = Arrangement.spacedBy(0.dp),
+ ) {
+ item {
+ val pref = CollectionViewOptions.SeparateTypes
+ val interactionSource = remember { MutableInteractionSource() }
+ ComposablePreference(
+ preference = pref,
+ value = viewOptions.separateTypes,
+ onNavigate = {},
+ onValueChange = { newValue ->
+ onViewOptionsChange.invoke(pref.setter(viewOptions, newValue))
+ },
+ interactionSource = interactionSource,
+ modifier = Modifier,
+ onClickPreference = {},
+ )
+ }
+ items(options, key = { it.title }) { pref ->
+ pref as AppPreference
+ val interactionSource = remember { MutableInteractionSource() }
+ val value = pref.getter.invoke(viewOptions.cardViewOptions)
+ ComposablePreference(
+ preference = pref,
+ value = value,
+ onNavigate = {},
+ onValueChange = { newValue ->
+ val newCardViewOptions =
+ pref.setter(viewOptions.cardViewOptions, newValue)
+ onViewOptionsChange.invoke(viewOptions.copy(cardViewOptions = newCardViewOptions))
+ },
+ interactionSource = interactionSource,
+ modifier = Modifier.animateItem(),
+ onClickPreference = { pref ->
+ if (pref == ViewOptionsReset) {
+ onViewOptionsChange.invoke(defaultViewOptions)
+ }
+ },
+ )
+ }
+ }
+ }
+ }
+}
+
+@Serializable
+data class CollectionViewOptions(
+ val separateTypes: Boolean = true,
+ val cardViewOptions: ViewOptions =
+ ViewOptions(
+ showDetails = true,
+ showTitles = true,
+ ),
+) {
+ companion object {
+ val SeparateTypes =
+ AppSwitchPreference(
+ title = R.string.separate_types,
+ defaultValue = false,
+ getter = { it.separateTypes },
+ setter = { vo, value -> vo.copy(separateTypes = value) },
+ )
+
+ val MixedOptions =
+ listOf(
+ ViewOptionsImageType,
+ ViewOptionsAspectRatio,
+ ViewOptionsDetailHeader,
+ ViewOptionsShowTitles,
+ ViewOptionsColumns,
+ ViewOptionsSpacing,
+ ViewOptionsContentScale,
+ ViewOptionsReset,
+ )
+
+ val SeparateOptions =
+ listOf(
+ ViewOptionsDetailHeader,
+ ViewOptionsShowTitles,
+ ViewOptionsReset,
+ )
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt
index a366e788..85743e3d 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt
@@ -341,7 +341,7 @@ fun DiscoverMovieDetailsContent(
item = item,
onClick = onClick,
onLongClick = onLongClick,
- showOverlay = false,
+ showOverlay = true,
modifier = mod,
)
},
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt
index 12acfd37..42d4ce05 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt
@@ -378,7 +378,7 @@ fun DiscoverSeriesDetailsContent(
item = item,
onClick = onClick,
onLongClick = onLongClick,
- showOverlay = false,
+ showOverlay = true,
modifier = mod,
)
},
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt
index e341923b..79637ee0 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt
@@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@@ -35,6 +36,7 @@ import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons
+import com.github.damontecres.wholphin.ui.components.HeaderUtils
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.Optional
import com.github.damontecres.wholphin.ui.components.chooseStream
@@ -85,6 +87,7 @@ fun EpisodeDetails(
var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) }
var showDeleteDialog by remember { mutableStateOf(null) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
+ val canDelete by viewModel.canDelete.collectAsState()
val preferredSubtitleLanguage =
viewModel.serverRepository.currentUserDto
@@ -146,12 +149,7 @@ fun EpisodeDetails(
},
overviewOnClick = {
overviewDialog =
- ItemDetailsDialogInfo(
- title = ep.name ?: context.getString(R.string.unknown),
- overview = ep.data.overview,
- genres = ep.data.genres.orEmpty(),
- files = ep.data.mediaSources.orEmpty(),
- )
+ ItemDetailsDialogInfo(ep)
},
moreOnClick = {
moreDialog =
@@ -173,6 +171,7 @@ fun EpisodeDetails(
chooseVersionParams(
context,
ep.data.mediaSources!!,
+ chosenStreams?.source?.id?.toUUIDOrNull(),
) { idx ->
val source = ep.data.mediaSources!![idx]
viewModel.savePlayVersion(
@@ -214,19 +213,13 @@ fun EpisodeDetails(
onShowOverview = {
val source = chosenStreams?.source ?: ep.data.mediaSources?.firstOrNull()
if (source != null) {
- overviewDialog =
- ItemDetailsDialogInfo(
- title = ep.name ?: context.getString(R.string.unknown),
- overview = ep.data.overview,
- genres = ep.data.genres.orEmpty(),
- files = listOf(source),
- )
+ overviewDialog = ItemDetailsDialogInfo(ep)
}
},
onClearChosenStreams = {
viewModel.clearChosenStreams(chosenStreams)
},
- canDelete = viewModel.canDelete,
+ canDelete = canDelete,
),
)
},
@@ -236,7 +229,7 @@ fun EpisodeDetails(
favoriteOnClick = {
viewModel.setFavorite(ep.id, !ep.favorite)
},
- canDelete = viewModel.canDelete,
+ canDelete = canDelete,
deleteOnClick = { showDeleteDialog = ep },
modifier = modifier,
)
@@ -330,7 +323,7 @@ fun EpisodeDetailsContent(
Box(modifier = modifier) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp),
- contentPadding = PaddingValues(vertical = 8.dp),
+ contentPadding = PaddingValues(bottom = 8.dp),
modifier = Modifier.fillMaxSize(),
) {
item {
@@ -350,7 +343,7 @@ fun EpisodeDetailsContent(
modifier =
Modifier
.fillMaxWidth()
- .padding(top = 32.dp, bottom = 16.dp),
+ .padding(top = HeaderUtils.topPadding, bottom = 16.dp),
)
ExpandablePlayButtons(
resumePosition = resumePosition,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt
index 9b8bda5c..df3b180a 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt
@@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.detail.episode
import android.content.Context
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
+import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
@@ -19,6 +20,8 @@ import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.deleteItem
+import com.github.damontecres.wholphin.ui.combinePair
+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.setValueOnMain
@@ -34,6 +37,11 @@ import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
@@ -68,11 +76,19 @@ class EpisodeViewModel
val item = MutableLiveData(null)
val chosenStreams = MutableLiveData(null)
- var canDelete: Boolean = false
- private set
+ val canDelete = MutableStateFlow(false)
init {
init()
+ viewModelScope.launchDefault {
+ item
+ .asFlow()
+ .filterNotNull()
+ .combinePair(userPreferencesService.flow.map { it.appPreferences })
+ .collectLatest { (item, preferences) ->
+ canDelete.update { mediaManagementService.canDelete(item, preferences) }
+ }
+ }
}
private fun fetchAndSetItem(): Deferred =
@@ -101,7 +117,6 @@ class EpisodeViewModel
) {
val prefs = userPreferencesService.getCurrent()
val item = fetchAndSetItem().await()
- canDelete = mediaManagementService.canDelete(item)
val result = itemPlaybackRepository.getSelectedTracks(item.id, item, prefs)
withContext(Dispatchers.Main) {
this@EpisodeViewModel.item.value = item
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt
index 4276330d..ce3fbd5b 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt
@@ -172,6 +172,9 @@ class LiveTvViewModel
}
}
+ /**
+ * Creates a list of [LocalDateTime] for each hour from now
+ */
private fun buildGuideTimes() =
buildList {
val start = LocalDateTime.now().roundDownToHalfHour()
@@ -194,15 +197,22 @@ class LiveTvViewModel
loading.setValueOnMain(LoadingState.Success)
}
+ /**
+ * Get live TV programs for a subset of channels
+ *
+ * @param guideStart The start timestamp to fetch from
+ * @param channels The full list of channels
+ * @param channelIndices The indices of which channels to fetch programs for
+ */
private suspend fun fetchPrograms(
guideStart: LocalDateTime,
channels: List,
- range: IntRange,
+ channelIndices: IntRange,
) = mutex.withLock {
val maxStartDate = guideStart.plusHours(MAX_HOURS).minusMinutes(1)
val minEndDate = guideStart.plusMinutes(1L)
- val channelsToFetch = channels.subList(range.first, range.last + 1)
- Timber.v("Fetching programs for $range channels ${channelsToFetch.size}")
+ val channelsToFetch = channels.subList(channelIndices.first, channelIndices.last + 1)
+ Timber.v("Fetching programs for $channelIndices channels ${channelsToFetch.size}")
val request =
GetProgramsDto(
maxStartDate = maxStartDate,
@@ -390,7 +400,7 @@ class LiveTvViewModel
Timber.d("Got ${fetchedPrograms.size} programs & ${finalProgramList.size} total programs")
withContext(Dispatchers.Main) {
this@LiveTvViewModel.programs.value =
- FetchedPrograms(range, finalProgramList, programsByChannel)
+ FetchedPrograms(channelIndices, finalProgramList, programsByChannel)
}
}
@@ -472,6 +482,11 @@ class LiveTvViewModel
private var focusLoadingJob: Job? = null
+ /**
+ * Callback when focusing on the EPG grid
+ *
+ * This determines if more programs/channels should be fetched based on the current position
+ */
fun onFocusChannel(position: RowColumn) {
channels.value?.let { channels ->
val fetchedRange = programs.value!!.range
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt
index 87a5dd75..64bf2f1c 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt
@@ -17,6 +17,7 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
@@ -28,10 +29,8 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import com.github.damontecres.wholphin.R
-import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.ExtrasItem
import com.github.damontecres.wholphin.data.model.BaseItem
-import com.github.damontecres.wholphin.data.model.Chapter
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.Trailer
@@ -51,6 +50,7 @@ import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons
+import com.github.damontecres.wholphin.ui.components.HeaderUtils
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.Optional
import com.github.damontecres.wholphin.ui.components.chooseStream
@@ -66,11 +66,12 @@ import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
+import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.util.DataLoadingState
+import com.github.damontecres.wholphin.util.DiscoverRequestType
import com.github.damontecres.wholphin.util.ExceptionHandler
-import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaStreamType
@@ -97,15 +98,7 @@ fun MovieDetails(
viewModel.init()
onPauseOrDispose { }
}
- val item by viewModel.item.observeAsState()
- val people by viewModel.people.observeAsState(listOf())
- val chapters by viewModel.chapters.observeAsState(listOf())
- val trailers by viewModel.trailers.observeAsState(listOf())
- val extras by viewModel.extras.observeAsState(listOf())
- val similar by viewModel.similar.observeAsState(listOf())
- val loading by viewModel.loading.observeAsState(LoadingState.Loading)
- val chosenStreams by viewModel.chosenStreams.observeAsState(null)
- val discovered by viewModel.discovered.collectAsState()
+ val state by viewModel.state.collectAsState()
var overviewDialog by remember { mutableStateOf(null) }
var moreDialog by remember { mutableStateOf(null) }
@@ -138,196 +131,178 @@ fun MovieDetails(
onClickDelete = { showDeleteDialog = it },
)
- when (val state = loading) {
- is LoadingState.Error -> {
- ErrorMessage(state, modifier)
+ when (val s = state.loading) {
+ is DataLoadingState.Error -> {
+ ErrorMessage(s, modifier)
}
- LoadingState.Loading,
- LoadingState.Pending,
+ DataLoadingState.Loading,
+ DataLoadingState.Pending,
-> {
LoadingPage(modifier)
}
- LoadingState.Success -> {
- item?.let { movie ->
- LifecycleResumeEffect(destination.itemId) {
- viewModel.maybePlayThemeSong(
- destination.itemId,
- preferences.appPreferences.interfacePreferences.playThemeSongs,
- )
- onPauseOrDispose {
- viewModel.release()
- }
- }
- MovieDetailsContent(
- preferences = preferences,
- movie = movie,
- chosenStreams = chosenStreams,
- people = people,
- chapters = chapters,
- extras = extras,
- trailers = trailers,
- similar = similar,
- onClickItem = { index, item ->
- viewModel.navigateTo(item.destination())
- },
- onClickPerson = {
- viewModel.navigateTo(
- Destination.MediaItem(
- it.id,
- BaseItemKind.PERSON,
- ),
- )
- },
- playOnClick = {
- viewModel.navigateTo(
- Destination.Playback(
- movie.id,
- it.inWholeMilliseconds,
- ),
- )
- },
- overviewOnClick = {
- overviewDialog =
- ItemDetailsDialogInfo(
- title = movie.name ?: context.getString(R.string.unknown),
- overview = movie.data.overview,
- genres = movie.data.genres.orEmpty(),
- files = movie.data.mediaSources.orEmpty(),
- )
- },
- moreOnClick = {
- moreDialog =
- DialogParams(
- fromLongClick = false,
- title = movie.name + " (${movie.data.productionYear ?: ""})",
- items =
- buildMoreDialogItems(
- context = context,
- item = movie,
- watched = movie.data.userData?.played ?: false,
- favorite = movie.data.userData?.isFavorite ?: false,
- seriesId = null,
- sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
- canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
- canDelete = viewModel.canDelete,
- actions = moreActions,
- onChooseVersion = {
- chooseVersion =
- chooseVersionParams(
- context,
- movie.data.mediaSources!!,
- ) { idx ->
- val source = movie.data.mediaSources!![idx]
- viewModel.savePlayVersion(
- movie,
- source.id!!.toUUID(),
- )
- }
- moreDialog = null
- },
- onChooseTracks = { type ->
-
- viewModel.streamChoiceService
- .chooseSource(
- movie.data,
- chosenStreams?.itemPlayback,
- )?.let { source ->
- chooseVersion =
- chooseStream(
- context = context,
- streams = source.mediaStreams.orEmpty(),
- type = type,
- currentIndex =
- if (type == MediaStreamType.AUDIO) {
- chosenStreams?.audioStream?.index
- } else {
- chosenStreams?.subtitleStream?.index
- },
- onClick = { trackIndex ->
- viewModel.saveTrackSelection(
- movie,
- chosenStreams?.itemPlayback,
- trackIndex,
- type,
- )
- },
- preferredSubtitleLanguage = preferredSubtitleLanguage,
- )
- }
- },
- onShowOverview = {
- overviewDialog =
- ItemDetailsDialogInfo(
- title =
- movie.name
- ?: context.getString(R.string.unknown),
- overview = movie.data.overview,
- genres = movie.data.genres.orEmpty(),
- files = movie.data.mediaSources.orEmpty(),
- )
- },
- onClearChosenStreams = {
- viewModel.clearChosenStreams(chosenStreams)
- },
- ),
- )
- },
- watchOnClick = {
- viewModel.setWatched(movie.id, !movie.played)
- },
- favoriteOnClick = {
- viewModel.setFavorite(movie.id, !movie.favorite)
- },
- onLongClickPerson = { index, person ->
- val items =
- buildMoreDialogItemsForPerson(
- context = context,
- person = person,
- actions = moreActions,
- )
- moreDialog =
- DialogParams(
- fromLongClick = true,
- title = person.name ?: "",
- items = items,
- )
- },
- onLongClickSimilar = { index, similar ->
- val items =
- buildMoreDialogItemsForHome(
- context = context,
- item = similar,
- seriesId = null,
- playbackPosition = similar.playbackPosition,
- watched = similar.played,
- favorite = similar.favorite,
- canDelete = false,
- actions = moreActions,
- )
- moreDialog =
- DialogParams(
- fromLongClick = true,
- title = similar.title ?: "",
- items = items,
- )
- },
- trailerOnClick = {
- TrailerService.onClick(context, it, viewModel::navigateTo)
- },
- onClickExtra = { index, extra ->
- viewModel.navigateTo(extra.destination)
- },
- discovered = discovered,
- onClickDiscover = { index, item ->
- viewModel.navigateTo(item.destination)
- },
- canDelete = viewModel.canDelete,
- deleteOnClick = { showDeleteDialog = movie },
- modifier = modifier,
+ is DataLoadingState.Success -> {
+ val unknownStr = stringResource(R.string.unknown)
+ val movie by rememberUpdatedState(s.data)
+ val chosenStreams by rememberUpdatedState(state.chosenStreams)
+ LifecycleResumeEffect(destination.itemId) {
+ viewModel.maybePlayThemeSong(
+ destination.itemId,
+ preferences.appPreferences.interfacePreferences.playThemeSongs,
)
+ onPauseOrDispose {
+ viewModel.release()
+ }
}
+ MovieDetailsContent(
+ preferences = preferences,
+ movie = movie,
+ state = state,
+ onClickItem = { index, item ->
+ viewModel.navigateTo(item.destination())
+ },
+ onClickPerson = {
+ viewModel.navigateTo(
+ Destination.MediaItem(
+ it.id,
+ BaseItemKind.PERSON,
+ ),
+ )
+ },
+ playOnClick = {
+ viewModel.navigateTo(
+ Destination.Playback(
+ movie.id,
+ it.inWholeMilliseconds,
+ ),
+ )
+ },
+ overviewOnClick = {
+ overviewDialog =
+ ItemDetailsDialogInfo(movie)
+ },
+ moreOnClick = {
+ moreDialog =
+ DialogParams(
+ fromLongClick = false,
+ title = movie.name + " (${movie.data.productionYear ?: ""})",
+ items =
+ buildMoreDialogItems(
+ context = context,
+ item = movie,
+ watched = movie.data.userData?.played ?: false,
+ favorite = movie.data.userData?.isFavorite ?: false,
+ seriesId = null,
+ sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
+ canClearChosenStreams = chosenStreams.let { it?.itemPlayback != null || it?.plc != null },
+ canDelete = state.canDelete,
+ actions = moreActions,
+ onChooseVersion = {
+ chooseVersion =
+ chooseVersionParams(
+ context,
+ movie.data.mediaSources!!,
+ chosenStreams?.source?.id?.toUUIDOrNull(),
+ ) { idx ->
+ val source = movie.data.mediaSources!![idx]
+ viewModel.savePlayVersion(
+ movie,
+ source.id!!.toUUID(),
+ )
+ }
+ moreDialog = null
+ },
+ onChooseTracks = { type ->
+ viewModel.streamChoiceService
+ .chooseSource(
+ movie.data,
+ chosenStreams?.itemPlayback,
+ )?.let { source ->
+ chooseVersion =
+ chooseStream(
+ context = context,
+ streams = source.mediaStreams.orEmpty(),
+ type = type,
+ currentIndex =
+ if (type == MediaStreamType.AUDIO) {
+ chosenStreams?.audioStream?.index
+ } else {
+ chosenStreams?.subtitleStream?.index
+ },
+ onClick = { trackIndex ->
+ viewModel.saveTrackSelection(
+ movie,
+ chosenStreams?.itemPlayback,
+ trackIndex,
+ type,
+ )
+ },
+ preferredSubtitleLanguage = preferredSubtitleLanguage,
+ )
+ }
+ },
+ onShowOverview = {
+ overviewDialog = ItemDetailsDialogInfo(movie)
+ },
+ onClearChosenStreams = {
+ viewModel.clearChosenStreams(chosenStreams)
+ },
+ ),
+ )
+ },
+ watchOnClick = {
+ viewModel.setWatched(movie.id, !movie.played)
+ },
+ favoriteOnClick = {
+ viewModel.setFavorite(movie.id, !movie.favorite)
+ },
+ onLongClickPerson = { index, person ->
+ val items =
+ buildMoreDialogItemsForPerson(
+ context = context,
+ person = person,
+ actions = moreActions,
+ )
+ moreDialog =
+ DialogParams(
+ fromLongClick = true,
+ title = person.name ?: "",
+ items = items,
+ )
+ },
+ onLongClickSimilar = { index, similar ->
+ val items =
+ buildMoreDialogItemsForHome(
+ context = context,
+ item = similar,
+ seriesId = null,
+ playbackPosition = similar.playbackPosition,
+ watched = similar.played,
+ favorite = similar.favorite,
+ canDelete = false,
+ actions = moreActions,
+ )
+ moreDialog =
+ DialogParams(
+ fromLongClick = true,
+ title = similar.title ?: "",
+ items = items,
+ )
+ },
+ trailerOnClick = {
+ TrailerService.onClick(context, it, viewModel::navigateTo)
+ },
+ onClickExtra = { index, extra ->
+ viewModel.navigateTo(extra.destination)
+ },
+ onClickDiscover = { index, item ->
+ viewModel.navigateTo(item.destination)
+ },
+ canDelete = state.canDelete,
+ deleteOnClick = { showDeleteDialog = state.movie },
+ modifier = modifier,
+ )
}
}
overviewDialog?.let { info ->
@@ -401,13 +376,7 @@ private const val DISCOVER_ROW = SIMILAR_ROW + 1
fun MovieDetailsContent(
preferences: UserPreferences,
movie: BaseItem,
- chosenStreams: ChosenStreams?,
- people: List,
- chapters: List,
- trailers: List,
- extras: List,
- similar: List,
- discovered: List,
+ state: MovieState,
playOnClick: (Duration) -> Unit,
trailerOnClick: (Trailer) -> Unit,
overviewOnClick: () -> Unit,
@@ -438,7 +407,7 @@ fun MovieDetailsContent(
Box(modifier = modifier) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp),
- contentPadding = PaddingValues(vertical = 8.dp),
+ contentPadding = PaddingValues(bottom = 8.dp),
modifier = Modifier.fillMaxSize(),
) {
item {
@@ -452,13 +421,13 @@ fun MovieDetailsContent(
MovieDetailsHeader(
preferences = preferences,
movie = movie,
- chosenStreams = chosenStreams,
+ chosenStreams = state.chosenStreams,
bringIntoViewRequester = bringIntoViewRequester,
overviewOnClick = overviewOnClick,
modifier =
Modifier
.fillMaxWidth()
- .padding(top = 40.dp, bottom = 16.dp),
+ .padding(top = HeaderUtils.topPadding, bottom = 16.dp),
)
ExpandablePlayButtons(
resumePosition = resumePosition,
@@ -479,7 +448,7 @@ fun MovieDetailsContent(
}
}
},
- trailers = trailers,
+ trailers = state.trailers,
trailerOnClick = {
position = TRAILER_ROW
trailerOnClick.invoke(it)
@@ -494,7 +463,7 @@ fun MovieDetailsContent(
)
}
}
- if (people.isNotEmpty()) {
+ state.people.letNotEmpty { people ->
item {
PersonRow(
people = people,
@@ -513,7 +482,7 @@ fun MovieDetailsContent(
)
}
}
- if (chapters.isNotEmpty()) {
+ state.chapters.letNotEmpty { chapters ->
item {
ChapterRow(
chapters = chapters,
@@ -530,7 +499,7 @@ fun MovieDetailsContent(
)
}
}
- if (extras.isNotEmpty()) {
+ state.extras.letNotEmpty { extras ->
item {
ExtrasRow(
extras = extras,
@@ -546,7 +515,7 @@ fun MovieDetailsContent(
)
}
}
- if (similar.isNotEmpty()) {
+ state.similar.letNotEmpty { similar ->
item {
val imageHeight =
remember(movie.type) {
@@ -585,13 +554,14 @@ fun MovieDetailsContent(
)
}
}
- if (discovered.isNotEmpty()) {
+ state.discovered.letNotEmpty { discovered ->
item {
DiscoverRow(
row =
DiscoverRowData(
stringResource(R.string.discover),
DataLoadingState.Success(discovered),
+ type = DiscoverRequestType.UNKNOWN,
),
onClickItem = { index: Int, item: DiscoverItem ->
position = DISCOVER_ROW
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt
index 45e3ee54..ee8f74f0 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt
@@ -13,8 +13,6 @@ import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
@@ -24,8 +22,10 @@ import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.GenreText
+import com.github.damontecres.wholphin.ui.components.HeaderUtils
import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.QuickDetails
+import com.github.damontecres.wholphin.ui.components.TitleOrLogo
import com.github.damontecres.wholphin.ui.components.VideoStreamDetails
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty
@@ -50,16 +50,13 @@ fun MovieDetailsHeader(
modifier = modifier,
) {
// Title
- Text(
- text = movie.name ?: "",
- color = MaterialTheme.colorScheme.onBackground,
- style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
+ TitleOrLogo(
+ item = movie,
+ showLogo = preferences.appPreferences.interfacePreferences.showLogos,
modifier =
Modifier
.fillMaxWidth(.75f)
- .padding(start = 8.dp),
+ .padding(start = HeaderUtils.startPadding),
)
Column(
@@ -69,24 +66,29 @@ fun MovieDetailsHeader(
QuickDetails(
movie.ui.quickDetails,
movie.timeRemainingOrRuntime,
- Modifier.padding(start = 8.dp),
+ Modifier.padding(start = HeaderUtils.startPadding),
)
dto.genres?.letNotEmpty {
- GenreText(it, Modifier.padding(start = 8.dp))
+ GenreText(it, Modifier.padding(start = HeaderUtils.startPadding))
}
VideoStreamDetails(
chosenStreams = chosenStreams,
numberOfVersions = movie.data.mediaSourceCount ?: 0,
- modifier = Modifier.padding(start = 8.dp, top = 4.dp, bottom = 16.dp),
+ modifier =
+ Modifier.padding(
+ start = HeaderUtils.startPadding,
+ top = 4.dp,
+ bottom = 16.dp,
+ ),
)
dto.taglines?.firstOrNull()?.let { tagline ->
Text(
text = tagline,
style = MaterialTheme.typography.bodyLarge,
fontStyle = FontStyle.Italic,
- modifier = Modifier.padding(start = 8.dp),
+ modifier = Modifier.padding(start = HeaderUtils.startPadding),
)
}
@@ -122,7 +124,7 @@ fun MovieDetailsHeader(
text = stringResource(R.string.directed_by, it),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
- modifier = Modifier.padding(start = 8.dp),
+ modifier = Modifier.padding(start = HeaderUtils.startPadding),
)
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt
index 993260dc..82518409 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt
@@ -1,7 +1,6 @@
package com.github.damontecres.wholphin.ui.detail.movie
import android.content.Context
-import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ChosenStreams
@@ -29,31 +28,28 @@ import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.deleteItem
import com.github.damontecres.wholphin.ui.SlimItemFields
+import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination
-import com.github.damontecres.wholphin.ui.setValueOnMain
-import com.github.damontecres.wholphin.util.ExceptionHandler
-import com.github.damontecres.wholphin.util.LoadingExceptionHandler
-import com.github.damontecres.wholphin.util.LoadingState
+import com.github.damontecres.wholphin.ui.showToast
+import com.github.damontecres.wholphin.util.DataLoadingState
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.Job
-import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.libraryApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest
+import timber.log.Timber
import java.util.UUID
@HiltViewModel(assistedFactory = MovieViewModel.Factory::class)
@@ -83,87 +79,99 @@ class MovieViewModel
fun create(itemId: UUID): MovieViewModel
}
- val loading = MutableLiveData(LoadingState.Pending)
- val item = MutableLiveData(null)
- val trailers = MutableLiveData>(listOf())
- val people = MutableLiveData>(listOf())
- val chapters = MutableLiveData>(listOf())
- val extras = MutableLiveData>(listOf())
- val similar = MutableLiveData>()
- val chosenStreams = MutableLiveData(null)
- val discovered = MutableStateFlow>(listOf())
-
- var canDelete: Boolean = false
- private set
+ private val _state = MutableStateFlow(MovieState())
+ val state: StateFlow = _state
init {
init()
+ viewModelScope.launchDefault {
+ userPreferencesService.flow.collectLatest { preferences ->
+ _state.update {
+ val canDelete =
+ it.movie?.let {
+ mediaManagementService.canDelete(
+ it,
+ preferences.appPreferences,
+ )
+ }
+ it.copy(
+ canDelete = canDelete ?: false,
+ )
+ }
+ }
+ }
}
- private fun fetchAndSetItem(): Deferred =
- viewModelScope.async(
- Dispatchers.IO +
- LoadingExceptionHandler(
- loading,
- "Error fetching movie",
- ),
- ) {
- val item =
- api.userLibraryApi.getItem(itemId).content.let {
- BaseItem.from(it, api)
- }
- canDelete = mediaManagementService.canDelete(item)
- this@MovieViewModel.item.setValueOnMain(item)
- item
- }
+ private suspend fun getMovie(): BaseItem {
+ val item =
+ api.userLibraryApi.getItem(itemId).content.let {
+ BaseItem(it)
+ }
+ return item
+ }
fun init(): Job =
- viewModelScope.launch(
- Dispatchers.IO +
- LoadingExceptionHandler(
- loading,
- "Error fetching movie",
- ),
- ) {
- val item = fetchAndSetItem().await()
- val result =
+ viewModelScope.launchDefault {
+ val movie =
+ try {
+ getMovie()
+ } catch (ex: Exception) {
+ Timber.e(ex, "Failed to fetch movie %s", itemId)
+ _state.update { it.copy(loading = DataLoadingState.Error(ex)) }
+ return@launchDefault
+ }
+ val chosenStreams =
itemPlaybackRepository.getSelectedTracks(
- item.id,
- item,
+ itemId,
+ movie,
userPreferencesService.getCurrent(),
)
- val remoteTrailers = trailerService.getRemoteTrailers(item)
- withContext(Dispatchers.Main) {
- this@MovieViewModel.item.value = item
- chosenStreams.value = result
- this@MovieViewModel.trailers.value = remoteTrailers
- loading.value = LoadingState.Success
- backdropService.submit(item)
+ val remoteTrailers = trailerService.getRemoteTrailers(movie)
+ val chapters = Chapter.fromDto(movie.data, api)
+ _state.update {
+ it.copy(
+ loading = DataLoadingState.Success(movie),
+ chosenStreams = chosenStreams,
+ trailers = remoteTrailers,
+ chapters = chapters,
+ )
}
+ backdropService.submit(movie)
viewModelScope.launchIO {
- trailerService.getLocalTrailers(item).letNotEmpty { localTrailers ->
- withContext(Dispatchers.Main) {
- this@MovieViewModel.trailers.value = localTrailers + remoteTrailers
+ trailerService.getLocalTrailers(movie).letNotEmpty { localTrailers ->
+ _state.update {
+ it.copy(
+ trailers = localTrailers + remoteTrailers,
+ )
}
}
}
viewModelScope.launchIO {
- val people = peopleFavorites.getPeopleFor(item)
- this@MovieViewModel.people.setValueOnMain(people)
+ val people = peopleFavorites.getPeopleFor(movie)
+ _state.update {
+ it.copy(
+ people = people,
+ )
+ }
}
viewModelScope.launchIO {
- val extras = extrasService.getExtras(item.id)
- this@MovieViewModel.extras.setValueOnMain(extras)
+ val extras = extrasService.getExtras(itemId)
+ _state.update {
+ it.copy(
+ extras = extras,
+ )
+ }
}
viewModelScope.launchIO {
- val results = seerrService.similar(item).orEmpty()
- discovered.update { results }
+ val results = seerrService.similar(movie).orEmpty()
+ _state.update {
+ it.copy(
+ discovered = results,
+ )
+ }
}
- withContext(Dispatchers.Main) {
- chapters.value = Chapter.fromDto(item.data, api)
- }
- if (!similar.isInitialized) {
+ if (state.value.similar.isEmpty()) {
val similar =
api.libraryApi
.getSimilarItems(
@@ -174,31 +182,48 @@ class MovieViewModel
limit = 25,
),
).content.items
- .map { BaseItem.Companion.from(it, api) }
- this@MovieViewModel.similar.setValueOnMain(similar)
+ .map { BaseItem(it) }
+
+ _state.update { it.copy(similar = similar) }
}
}
fun setWatched(
itemId: UUID,
played: Boolean,
- ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
- favoriteWatchManager.setWatched(itemId, played)
- fetchAndSetItem()
+ ) = viewModelScope.launchDefault {
+ try {
+ favoriteWatchManager.setWatched(itemId, played)
+ getMovie().let { movie ->
+ _state.update {
+ it.copy(loading = DataLoadingState.Success(movie))
+ }
+ }
+ } catch (ex: Exception) {
+ Timber.e(ex, "Error updating watch status for movie %s", itemId)
+ showToast(context, "Something went wrong...")
+ }
}
fun setFavorite(
itemId: UUID,
favorite: Boolean,
- ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
- favoriteWatchManager.setFavorite(itemId, favorite)
- val item = item.value
- fetchAndSetItem()
- if (item != null && itemId != item.id) {
- viewModelScope.launchIO {
- val people = peopleFavorites.getPeopleFor(item)
- this@MovieViewModel.people.setValueOnMain(people)
+ ) = viewModelScope.launchDefault {
+ try {
+ favoriteWatchManager.setFavorite(itemId, favorite)
+ val movie = getMovie()
+ _state.update {
+ it.copy(loading = DataLoadingState.Success(movie))
}
+ if (itemId != movie.id) {
+ viewModelScope.launchIO {
+ val people = peopleFavorites.getPeopleFor(movie)
+ _state.update { it.copy(people = people) }
+ }
+ }
+ } catch (ex: Exception) {
+ Timber.e(ex, "Error updating favorite %s", itemId)
+ showToast(context, "Something went wrong...")
}
}
@@ -214,9 +239,7 @@ class MovieViewModel
result?.let {
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
}
- withContext(Dispatchers.Main) {
- chosenStreams.value = chosen
- }
+ _state.update { it.copy(chosenStreams = chosen) }
}
}
@@ -240,9 +263,7 @@ class MovieViewModel
result?.let {
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
}
- withContext(Dispatchers.Main) {
- chosenStreams.value = chosen
- }
+ _state.update { it.copy(chosenStreams = chosen) }
}
}
@@ -270,14 +291,14 @@ class MovieViewModel
fun clearChosenStreams(chosenStreams: ChosenStreams?) {
viewModelScope.launchIO {
itemPlaybackRepository.deleteChosenStreams(chosenStreams)
- item.value?.let { item ->
+ state.value.movie?.let { item ->
val result =
itemPlaybackRepository.getSelectedTracks(
itemId,
item,
userPreferencesService.getCurrent(),
)
- this@MovieViewModel.chosenStreams.setValueOnMain(result)
+ _state.update { it.copy(chosenStreams = result) }
}
}
}
@@ -288,3 +309,17 @@ class MovieViewModel
}
}
}
+
+data class MovieState(
+ val loading: DataLoadingState = DataLoadingState.Pending,
+ val trailers: List = emptyList(),
+ val people: List = emptyList(),
+ val chapters: List = emptyList(),
+ val extras: List = emptyList(),
+ val similar: List = emptyList(),
+ val discovered: List = emptyList(),
+ val chosenStreams: ChosenStreams? = null,
+ val canDelete: Boolean = false,
+) {
+ val movie: BaseItem? = (loading as? DataLoadingState.Success)?.data
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt
new file mode 100644
index 00000000..53cdb444
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt
@@ -0,0 +1,724 @@
+package com.github.damontecres.wholphin.ui.detail.music
+
+import android.content.Context
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.relocation.BringIntoViewRequester
+import androidx.compose.foundation.relocation.bringIntoViewRequester
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import coil3.compose.AsyncImage
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.data.ServerRepository
+import com.github.damontecres.wholphin.data.model.BaseItem
+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.ImageUrlService
+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.UserPreferencesService
+import com.github.damontecres.wholphin.ui.AspectRatios
+import com.github.damontecres.wholphin.ui.DefaultItemFields
+import com.github.damontecres.wholphin.ui.SlimItemFields
+import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle
+import com.github.damontecres.wholphin.ui.cards.ItemRow
+import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
+import com.github.damontecres.wholphin.ui.components.DialogParams
+import com.github.damontecres.wholphin.ui.components.DialogPopup
+import com.github.damontecres.wholphin.ui.components.ErrorMessage
+import com.github.damontecres.wholphin.ui.components.GenreText
+import com.github.damontecres.wholphin.ui.components.LoadingPage
+import com.github.damontecres.wholphin.ui.components.Optional
+import com.github.damontecres.wholphin.ui.components.OverviewText
+import com.github.damontecres.wholphin.ui.components.QuickDetails
+import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
+import com.github.damontecres.wholphin.ui.data.RowColumn
+import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
+import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
+import com.github.damontecres.wholphin.ui.ifElse
+import com.github.damontecres.wholphin.ui.launchDefault
+import com.github.damontecres.wholphin.ui.launchIO
+import com.github.damontecres.wholphin.ui.letNotEmpty
+import com.github.damontecres.wholphin.ui.rememberPosition
+import com.github.damontecres.wholphin.ui.toBaseItems
+import com.github.damontecres.wholphin.ui.tryRequestFocus
+import com.github.damontecres.wholphin.util.ApiRequestPager
+import com.github.damontecres.wholphin.util.ExceptionHandler
+import com.github.damontecres.wholphin.util.GetItemsRequestHandler
+import com.github.damontecres.wholphin.util.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.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import org.jellyfin.sdk.api.client.ApiClient
+import org.jellyfin.sdk.api.client.extensions.itemsApi
+import org.jellyfin.sdk.api.client.extensions.libraryApi
+import org.jellyfin.sdk.api.client.extensions.userLibraryApi
+import org.jellyfin.sdk.model.api.BaseItemKind
+import org.jellyfin.sdk.model.api.ImageType
+import org.jellyfin.sdk.model.api.MediaType
+import org.jellyfin.sdk.model.api.request.GetItemsRequest
+import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest
+import java.util.UUID
+
+@HiltViewModel(assistedFactory = AlbumViewModel.Factory::class)
+class AlbumViewModel
+ @AssistedInject
+ constructor(
+ @ApplicationContext context: Context,
+ api: ApiClient,
+ musicService: MusicService,
+ navigationManager: NavigationManager,
+ mediaManagementService: MediaManagementService,
+ val serverRepository: ServerRepository,
+ val mediaReportService: MediaReportService,
+ private val favoriteWatchManager: FavoriteWatchManager,
+ private val userPreferencesService: UserPreferencesService,
+ private val backdropService: BackdropService,
+ private val imageUrlService: ImageUrlService,
+ @Assisted itemId: UUID,
+ ) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) {
+ @AssistedFactory
+ interface Factory {
+ fun create(itemId: UUID): AlbumViewModel
+ }
+
+ private val _state = MutableStateFlow(AlbumState.EMPTY)
+ val state: StateFlow = _state
+
+ val currentMusic = musicService.state
+
+ init {
+ init()
+ }
+
+ override fun init() {
+ viewModelScope.launchIO {
+ try {
+ val itemDeferred =
+ async {
+ api.userLibraryApi
+ .getItem(itemId = itemId)
+ .content
+ .let { BaseItem(it, false) }
+ }
+ val songsDeferred = async { getPagerForAlbum(api, itemId) }
+ val album = itemDeferred.await()
+ val songs = songsDeferred.await()
+ val imageUrl = imageUrlService.getItemImageUrl(album, ImageType.PRIMARY)
+ val allArtists =
+ album.data.artists.orEmpty() +
+ album.data.albumArtists
+ ?.map { it.name }
+ .orEmpty()
+ val isVariousArtists =
+ allArtists.firstOrNull { it?.lowercase() == "various artists" } != null ||
+ album.data.artists
+ .orEmpty()
+ .size > 1
+ _state.update {
+ it.copy(
+ album = album,
+ isVariousArtists = isVariousArtists,
+ imageUrl = imageUrl,
+ songs = songs,
+ loading = LoadingState.Success,
+ )
+ }
+ updateBackDrop()
+ if (state.value.similar.isEmpty()) {
+ viewModelScope.launchIO {
+ val similar =
+ api.libraryApi
+ .getSimilarItems(
+ GetSimilarItemsRequest(
+ userId = serverRepository.currentUser.value?.id,
+ itemId = itemId,
+ excludeArtistIds = album.data.albumArtists?.map { it.id },
+ fields = SlimItemFields,
+ limit = 25,
+ ),
+ ).content.items
+ .map { BaseItem.from(it, api) }
+ _state.update { it.copy(similar = similar) }
+ }
+ }
+ viewModelScope.launchIO {
+ val request =
+ GetItemsRequest(
+ userId = serverRepository.currentUser.value?.id,
+ albumIds = listOf(itemId),
+ parentId = null,
+ fields = DefaultItemFields,
+ recursive = true,
+ includeItemTypes = listOf(BaseItemKind.MUSIC_VIDEO),
+ )
+ val musicVideos =
+ GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
+ if (musicVideos.isNotEmpty()) {
+ _state.update {
+ it.copy(musicVideos = musicVideos)
+ }
+ }
+ }
+ } catch (ex: Exception) {
+ _state.update { it.copy(loading = LoadingState.Error(ex)) }
+ }
+ }
+ }
+
+ fun updateBackDrop() {
+ state.value.album?.let { album ->
+ viewModelScope.launchDefault {
+ getBackdropItemForAlbum(api, album)?.let {
+ backdropService.submit(it)
+ }
+ }
+ }
+ }
+
+ fun setFavorite(
+ itemId: UUID,
+ favorite: Boolean,
+ ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
+ favoriteWatchManager.setFavorite(itemId, favorite)
+ val album =
+ api.userLibraryApi
+ .getItem(itemId = itemId)
+ .content
+ .let { BaseItem(it, false) }
+ _state.update { it.copy(album = album) }
+ }
+
+ fun play(
+ shuffled: Boolean,
+ startIndex: Int = 0,
+ ) {
+ val songs = state.value.songs as ApiRequestPager<*>
+ play(songs, startIndex, shuffled)
+ }
+ }
+
+suspend fun getBackdropItemForAlbum(
+ api: ApiClient,
+ album: BaseItem,
+): BaseItem? {
+ if (album.data.backdropImageTags?.isNotEmpty() == true) {
+ return album
+ } else {
+ val artistIds =
+ album.data.albumArtists
+ ?.shuffled()
+ ?.take(50)
+ ?.map { it.id }
+ return api.itemsApi
+ .getItems(
+ ids = artistIds,
+ imageTypes = listOf(ImageType.BACKDROP),
+ ).content.items
+ .firstOrNull()
+ ?.let { BaseItem(it, false) }
+ }
+}
+
+data class AlbumState(
+ val album: BaseItem?,
+ val isVariousArtists: Boolean,
+ val imageUrl: String?,
+ val songs: List,
+ val similar: List,
+ val loading: LoadingState,
+ val musicVideos: List = emptyList(),
+) {
+ companion object {
+ val EMPTY = AlbumState(null, false, null, emptyList(), emptyList(), LoadingState.Pending)
+ }
+}
+
+private const val HEADER_ROW = 0
+private const val SONG_ROW = HEADER_ROW + 1
+private const val MUSIC_VIDEO_ROW = SONG_ROW + 1
+private const val SIMILAR_ROW = MUSIC_VIDEO_ROW + 1
+
+@Composable
+fun AlbumDetailsPage(
+ itemId: UUID,
+ preferences: UserPreferences,
+ modifier: Modifier = Modifier,
+ viewModel: AlbumViewModel =
+ hiltViewModel(
+ creationCallback = { it.create(itemId) },
+ ),
+ playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
+) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ val state by viewModel.state.collectAsState()
+ val currentMusic by viewModel.currentMusic.collectAsState()
+
+ var position by rememberPosition(0, 0)
+ val focusRequesters =
+ remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
+ val focusManager = LocalFocusManager.current
+ var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) }
+ val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
+ var moreDialog by remember { mutableStateOf(null) }
+ var showDeleteDialog by remember { mutableStateOf(null) }
+ val moreDialogActions =
+ remember {
+ MusicMoreDialogActions(
+ onNavigate = { viewModel.navigationManager.navigateTo(it) },
+ onClickPlay = { index, _ -> viewModel.play(false, index) },
+ onClickPlayNext = { _, song -> viewModel.playNext(song) },
+ onClickAddToQueue = { index, item -> viewModel.addToQueue(item, index) },
+ onClickFavorite = { itemId, favorite -> viewModel.setFavorite(itemId, favorite) },
+ onClickAddPlaylist = { itemId ->
+ playlistViewModel.loadPlaylists(MediaType.AUDIO)
+ showPlaylistDialog.makePresent(itemId)
+ },
+ onClickRemoveFromQueue = {},
+ onClickDelete = { showDeleteDialog = it },
+ )
+ }
+ when (val loading = state.loading) {
+ is LoadingState.Error -> {
+ ErrorMessage(loading, modifier)
+ }
+
+ LoadingState.Loading,
+ LoadingState.Pending,
+ -> {
+ LoadingPage(modifier)
+ }
+
+ LoadingState.Success -> {
+ val album = state.album!!
+
+ val firstFocusRequester = remember { FocusRequester() }
+ val firstBringIntoViewRequester = remember { BringIntoViewRequester() }
+ val bringIntoViewRequester = remember { BringIntoViewRequester() }
+ val listState = rememberLazyListState()
+ val itemsBefore = 2
+
+ val songFocusRequester = remember { FocusRequester() }
+ LaunchedEffect(Unit) {
+ if (position.row == SONG_ROW) {
+ songFocusRequester.tryRequestFocus()
+ } else {
+ focusRequesters.getOrNull(position.row)?.tryRequestFocus()
+ }
+ viewModel.updateBackDrop()
+ }
+ val backHandlerActive by remember {
+ derivedStateOf {
+ listState.firstVisibleItemIndex > itemsBefore
+ }
+ }
+ BackHandler(backHandlerActive) {
+ scope.launch {
+ listState.animateScrollToItem(itemsBefore)
+ firstBringIntoViewRequester.bringIntoView()
+ firstFocusRequester.tryRequestFocus()
+ }
+ }
+ Box(modifier = modifier) {
+ LazyColumn(
+ state = listState,
+ verticalArrangement = Arrangement.spacedBy(0.dp),
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ item {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .bringIntoViewRequester(bringIntoViewRequester)
+ .padding(bottom = 32.dp),
+ ) {
+ AlbumHeader(
+ album = album,
+ imageUrl = state.imageUrl,
+ overviewOnClick = {},
+ bringIntoViewRequester = bringIntoViewRequester,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ MusicExpandableButtons(
+ actions =
+ remember {
+ MusicButtonActions(
+ onClickPlay = { viewModel.play(it, 0) },
+ onClickInstantMix = { viewModel.startInstantMix(album.id) },
+ onClickFavorite = {
+ viewModel.setFavorite(
+ album.id,
+ !album.favorite,
+ )
+ },
+ onClickMore = {
+ moreDialog =
+ DialogParams(
+ fromLongClick = false,
+ title = album.name + " (${album.data.productionYear ?: ""})",
+ items =
+ buildMoreDialogForMusic(
+ context = context,
+ actions = moreDialogActions,
+ item = album,
+ index = 0,
+ canRemove = false,
+ canDelete =
+ viewModel.canDelete(
+ album,
+ preferences.appPreferences,
+ ),
+ ),
+ )
+ },
+ )
+ },
+ favorite = album.favorite,
+ buttonOnFocusChanged = {
+ if (it.isFocused) {
+ position = RowColumn(HEADER_ROW, 0)
+ scope.launch { bringIntoViewRequester.bringIntoView() }
+ }
+ },
+ modifier =
+ Modifier
+ .onFocusChanged {
+ if (it.hasFocus) scope.launch { bringIntoViewRequester.bringIntoView() }
+ }.focusRequester(focusRequesters[HEADER_ROW]),
+ )
+ }
+ }
+ item {
+ Text(
+ text = stringResource(R.string.songs) + " (${state.songs.size})",
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onBackground,
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ }
+ itemsIndexed(state.songs) { index, song ->
+ Box(
+ modifier = Modifier.fillMaxWidth(),
+ contentAlignment = Alignment.Center,
+ ) {
+ SongListItem(
+ song = song,
+ onClick = {
+ position = RowColumn(SONG_ROW, index)
+ viewModel.play(false, index)
+ },
+ onLongClick = {
+ if (song != null) {
+ moreDialog =
+ DialogParams(
+ fromLongClick = true,
+ title = song.name ?: "",
+ items =
+ buildMoreDialogForMusic(
+ context = context,
+ actions = moreDialogActions,
+ item = song,
+ index = index,
+ canRemove = false,
+ canDelete =
+ viewModel.canDelete(
+ song,
+ preferences.appPreferences,
+ ),
+ ),
+ )
+ }
+ },
+ onClickMore = {
+ if (song != null) {
+ moreDialog =
+ DialogParams(
+ fromLongClick = false,
+ title = song.name ?: "",
+ items =
+ buildMoreDialogForMusic(
+ context = context,
+ actions = moreDialogActions,
+ item = song,
+ index = index,
+ canRemove = false,
+ canDelete =
+ viewModel.canDelete(
+ song,
+ preferences.appPreferences,
+ ),
+ ),
+ )
+ }
+ },
+ showArtist = state.isVariousArtists,
+ isPlaying = song != null && currentMusic.currentItemId == song.id,
+ isQueued = song != null && song.id in currentMusic.queuedIds,
+ modifier =
+ Modifier
+ .fillMaxWidth(.75f)
+ .ifElse(
+ index == 0,
+ Modifier
+ .focusRequester(firstFocusRequester)
+ .bringIntoViewRequester(firstBringIntoViewRequester),
+ ).ifElse(
+ position.row == SONG_ROW && position.column == index,
+ Modifier.focusRequester(songFocusRequester),
+ ),
+ )
+ }
+ }
+ if (state.musicVideos.isNotEmpty()) {
+ item {
+ ItemRow(
+ title = stringResource(R.string.music_videos),
+ items = state.musicVideos,
+ onClickItem = { index, item ->
+ position = RowColumn(MUSIC_VIDEO_ROW, index)
+ viewModel.navigationManager.navigateTo(item.destination())
+ },
+ onLongClickItem = { index, item ->
+ // TODO
+ },
+ cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
+ BannerCardWithTitle(
+ title = item?.name,
+ subtitle = item?.data?.productionYear?.toString(),
+ item = item,
+ onClick = onClick,
+ onLongClick = onLongClick,
+ aspectRatio = AspectRatios.WIDE,
+ played = item?.played ?: false,
+ playPercent = item?.data?.userData?.playedPercentage ?: 0.0,
+ favorite = item?.favorite ?: false,
+ modifier = mod,
+ )
+ },
+ modifier = Modifier.focusRequester(focusRequesters[MUSIC_VIDEO_ROW]),
+ )
+ }
+ }
+ if (state.similar.isNotEmpty()) {
+ item {
+ ItemRow(
+ title = stringResource(R.string.more_like_this),
+ items = state.similar,
+ onClickItem = { index, item ->
+ position = RowColumn(SIMILAR_ROW, index)
+ viewModel.navigationManager.navigateTo(item.destination())
+ },
+ onLongClickItem = { index, item ->
+ moreDialog =
+ DialogParams(
+ fromLongClick = true,
+ title = item.name ?: "",
+ items =
+ buildMoreDialogForMusic(
+ context = context,
+ actions = moreDialogActions,
+ item = item,
+ index = index,
+ canRemove = false,
+ canDelete =
+ viewModel.canDelete(
+ item,
+ preferences.appPreferences,
+ ),
+ ),
+ )
+ },
+ cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
+ BannerCardWithTitle(
+ title = item?.name,
+ subtitle = item?.data?.productionYear?.toString(),
+ item = item,
+ onClick = onClick,
+ onLongClick = onLongClick,
+ aspectRatio = AspectRatios.SQUARE,
+ modifier = mod,
+ )
+ },
+ modifier = Modifier.focusRequester(focusRequesters[SIMILAR_ROW]),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ moreDialog?.let { params ->
+ DialogPopup(
+ showDialog = true,
+ title = params.title,
+ dialogItems = params.items,
+ onDismissRequest = { moreDialog = null },
+ dismissOnClick = true,
+ waitToLoad = params.fromLongClick,
+ )
+ }
+ showPlaylistDialog.compose { itemId ->
+ PlaylistDialog(
+ title = stringResource(R.string.add_to_playlist),
+ state = playlistState,
+ onDismissRequest = { showPlaylistDialog.makeAbsent() },
+ onClick = {
+ playlistViewModel.addToPlaylist(it.id, itemId)
+ showPlaylistDialog.makeAbsent()
+ },
+ createEnabled = true,
+ onCreatePlaylist = {
+ playlistViewModel.createPlaylistAndAddItem(it, itemId)
+ showPlaylistDialog.makeAbsent()
+ },
+ elevation = 3.dp,
+ )
+ }
+ showDeleteDialog?.let { item ->
+ ConfirmDeleteDialog(
+ itemTitle = item.title ?: "",
+ onCancel = { showDeleteDialog = null },
+ onConfirm = {
+ viewModel.deleteItem(item)
+ focusRequesters.getOrNull(position.row)?.tryRequestFocus()
+ showDeleteDialog = null
+ },
+ )
+ }
+}
+
+@Composable
+fun AlbumHeader(
+ album: BaseItem,
+ imageUrl: String?,
+ overviewOnClick: () -> Unit,
+ bringIntoViewRequester: BringIntoViewRequester,
+ modifier: Modifier,
+) {
+ val scope = rememberCoroutineScope()
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = modifier.padding(top = 32.dp),
+ ) {
+ AsyncImage(
+ model = imageUrl,
+ contentDescription = null,
+ contentScale = ContentScale.FillWidth,
+ modifier =
+ Modifier
+ .fillMaxWidth(.20f)
+ .clip(RoundedCornerShape(16.dp)),
+ )
+ Column(
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ // Artist
+ Text(
+ text = album.artistsString ?: "",
+ color = MaterialTheme.colorScheme.onBackground,
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier =
+ Modifier
+ .fillMaxWidth(.75f)
+ .padding(start = 8.dp),
+ )
+ Text(
+ text = album.name ?: "",
+ color = MaterialTheme.colorScheme.onBackground,
+ style = MaterialTheme.typography.headlineSmall,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier =
+ Modifier
+ .fillMaxWidth(.75f)
+ .padding(start = 8.dp),
+ )
+
+ Column(
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ modifier = Modifier.fillMaxWidth(.60f),
+ ) {
+ QuickDetails(
+ album.ui.quickDetails,
+ null,
+ Modifier.padding(start = 8.dp),
+ )
+
+ album.data.genres?.letNotEmpty {
+ GenreText(it, Modifier.padding(start = 8.dp))
+ }
+
+ // Description
+ album.data.overview?.let { overview ->
+ OverviewText(
+ overview = overview,
+ maxLines = 3,
+ onClick = overviewOnClick,
+ textBoxHeight = Dp.Unspecified,
+ modifier =
+ Modifier.onFocusChanged {
+ if (it.isFocused) {
+ scope.launch(ExceptionHandler()) {
+ bringIntoViewRequester.bringIntoView()
+ }
+ }
+ },
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt
new file mode 100644
index 00000000..4a338d03
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt
@@ -0,0 +1,753 @@
+package com.github.damontecres.wholphin.ui.detail.music
+
+import android.content.Context
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.relocation.BringIntoViewRequester
+import androidx.compose.foundation.relocation.bringIntoViewRequester
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import coil3.compose.AsyncImage
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.data.ServerRepository
+import com.github.damontecres.wholphin.data.model.BaseItem
+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.ImageUrlService
+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.UserPreferencesService
+import com.github.damontecres.wholphin.ui.AspectRatios
+import com.github.damontecres.wholphin.ui.DefaultItemFields
+import com.github.damontecres.wholphin.ui.SlimItemFields
+import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle
+import com.github.damontecres.wholphin.ui.cards.ItemRow
+import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
+import com.github.damontecres.wholphin.ui.components.DialogParams
+import com.github.damontecres.wholphin.ui.components.DialogPopup
+import com.github.damontecres.wholphin.ui.components.ErrorMessage
+import com.github.damontecres.wholphin.ui.components.GenreText
+import com.github.damontecres.wholphin.ui.components.LoadingPage
+import com.github.damontecres.wholphin.ui.components.Optional
+import com.github.damontecres.wholphin.ui.components.OverviewText
+import com.github.damontecres.wholphin.ui.components.QuickDetails
+import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
+import com.github.damontecres.wholphin.ui.data.RowColumn
+import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
+import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
+import com.github.damontecres.wholphin.ui.ifElse
+import com.github.damontecres.wholphin.ui.launchDefault
+import com.github.damontecres.wholphin.ui.launchIO
+import com.github.damontecres.wholphin.ui.letNotEmpty
+import com.github.damontecres.wholphin.ui.rememberPosition
+import com.github.damontecres.wholphin.ui.toBaseItems
+import com.github.damontecres.wholphin.ui.tryRequestFocus
+import com.github.damontecres.wholphin.util.ApiRequestPager
+import com.github.damontecres.wholphin.util.ExceptionHandler
+import com.github.damontecres.wholphin.util.GetItemsRequestHandler
+import com.github.damontecres.wholphin.util.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.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import org.jellyfin.sdk.api.client.ApiClient
+import org.jellyfin.sdk.api.client.extensions.libraryApi
+import org.jellyfin.sdk.api.client.extensions.userLibraryApi
+import org.jellyfin.sdk.model.api.BaseItemKind
+import org.jellyfin.sdk.model.api.ImageType
+import org.jellyfin.sdk.model.api.ItemSortBy
+import org.jellyfin.sdk.model.api.MediaType
+import org.jellyfin.sdk.model.api.SortOrder
+import org.jellyfin.sdk.model.api.request.GetItemsRequest
+import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest
+import java.util.UUID
+
+@HiltViewModel(assistedFactory = ArtistViewModel.Factory::class)
+class ArtistViewModel
+ @AssistedInject
+ constructor(
+ @ApplicationContext context: Context,
+ api: ApiClient,
+ musicService: MusicService,
+ navigationManager: NavigationManager,
+ mediaManagementService: MediaManagementService,
+ val serverRepository: ServerRepository,
+ val mediaReportService: MediaReportService,
+ private val favoriteWatchManager: FavoriteWatchManager,
+ private val userPreferencesService: UserPreferencesService,
+ private val backdropService: BackdropService,
+ private val imageUrlService: ImageUrlService,
+ @Assisted itemId: UUID,
+ ) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) {
+ @AssistedFactory
+ interface Factory {
+ fun create(itemId: UUID): ArtistViewModel
+ }
+
+ private val _state = MutableStateFlow(ArtistState.EMPTY)
+ val state: StateFlow = _state
+
+ val currentMusic = musicService.state
+
+ init {
+ init()
+ }
+
+ override fun init() {
+ viewModelScope.launchIO {
+ try {
+ val itemDeferred =
+ async {
+ api.userLibraryApi
+ .getItem(itemId = itemId)
+ .content
+ .let { BaseItem(it, false) }
+ }
+ val albumsDeferred =
+ async {
+ val request =
+ GetItemsRequest(
+ parentId = itemId,
+ fields = DefaultItemFields,
+ includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
+ sortBy =
+ listOf(
+ ItemSortBy.PREMIERE_DATE,
+ ItemSortBy.SORT_NAME,
+ ),
+ sortOrder = listOf(SortOrder.DESCENDING, SortOrder.ASCENDING),
+ )
+ ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init()
+ }
+ val artist = itemDeferred.await()
+ val albums = albumsDeferred.await()
+ val imageUrl = imageUrlService.getItemImageUrl(artist, ImageType.PRIMARY)
+ _state.update {
+ it.copy(
+ artist = artist,
+ imageUrl = imageUrl,
+ albums = albums,
+ loading = LoadingState.Success,
+ )
+ }
+
+ viewModelScope.launchIO {
+ val request =
+ GetItemsRequest(
+ artistIds = listOf(itemId),
+ fields = DefaultItemFields,
+ recursive = true,
+ includeItemTypes = listOf(BaseItemKind.AUDIO),
+ minCommunityRating = 1.0,
+ sortBy =
+ listOf(
+ ItemSortBy.COMMUNITY_RATING,
+ ),
+ sortOrder = listOf(SortOrder.DESCENDING),
+ limit = 10,
+ )
+ val topSongs =
+ GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
+ if (topSongs.isNotEmpty()) {
+ _state.update {
+ it.copy(topSongs = topSongs)
+ }
+ }
+ }
+ if (state.value.similar.isEmpty()) {
+ viewModelScope.launchIO {
+ val similar =
+ api.libraryApi
+ .getSimilarItems(
+ GetSimilarItemsRequest(
+ userId = serverRepository.currentUser.value?.id,
+ itemId = itemId,
+ excludeArtistIds = listOf(itemId),
+ fields = SlimItemFields,
+ limit = 25,
+ ),
+ ).content.items
+ .map { BaseItem.from(it, api) }
+ _state.update { it.copy(similar = similar) }
+ }
+ }
+ viewModelScope.launchIO {
+ val request =
+ GetItemsRequest(
+ userId = serverRepository.currentUser.value?.id,
+ artistIds = listOf(itemId),
+ parentId = null,
+ fields = DefaultItemFields,
+ recursive = true,
+ includeItemTypes = listOf(BaseItemKind.MUSIC_VIDEO),
+ )
+ val musicVideos =
+ GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
+ if (musicVideos.isNotEmpty()) {
+ _state.update {
+ it.copy(musicVideos = musicVideos)
+ }
+ }
+ }
+ } catch (ex: Exception) {
+ _state.update { it.copy(loading = LoadingState.Error(ex)) }
+ }
+ }
+ }
+
+ fun refresh() {
+ viewModelScope.launchDefault {
+ state.value.artist?.let {
+ backdropService.submit(it)
+ }
+ }
+ }
+
+ fun setFavorite(
+ itemId: UUID,
+ favorite: Boolean,
+ ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
+ favoriteWatchManager.setFavorite(itemId, favorite)
+ val artist =
+ api.userLibraryApi
+ .getItem(itemId = itemId)
+ .content
+ .let { BaseItem(it, false) }
+ _state.update { it.copy(artist = artist) }
+ }
+ }
+
+data class ArtistState(
+ val artist: BaseItem?,
+ val imageUrl: String?,
+ val topSongs: List,
+ val albums: List,
+ val similar: List,
+ val loading: LoadingState,
+ val musicVideos: List,
+) {
+ companion object {
+ val EMPTY =
+ ArtistState(
+ null,
+ null,
+ emptyList(),
+ emptyList(),
+ emptyList(),
+ LoadingState.Pending,
+ emptyList(),
+ )
+ }
+}
+
+private const val HEADER_ROW = 0
+private const val SONG_ROW = HEADER_ROW + 1
+private const val ALBUM_ROW = SONG_ROW + 1
+private const val MUSIC_VIDEO_ROW = ALBUM_ROW + 1
+private const val SIMILAR_ROW = MUSIC_VIDEO_ROW + 1
+
+@Composable
+fun ArtistDetailsPage(
+ preferences: UserPreferences,
+ itemId: UUID,
+ modifier: Modifier = Modifier,
+ viewModel: ArtistViewModel =
+ hiltViewModel(
+ creationCallback = { it.create(itemId) },
+ ),
+ playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
+) {
+ val context = LocalContext.current
+ val focusManager = LocalFocusManager.current
+ val scope = rememberCoroutineScope()
+ val state by viewModel.state.collectAsState()
+ val currentMusic by viewModel.currentMusic.collectAsState()
+ var position by rememberPosition(0, 0)
+ val focusRequesters =
+ remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
+
+ var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) }
+ val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
+ var moreDialog by remember { mutableStateOf(null) }
+ var showDeleteDialog by remember { mutableStateOf(null) }
+ val moreDialogActions =
+ remember {
+ MusicMoreDialogActions(
+ onNavigate = { viewModel.navigationManager.navigateTo(it) },
+ onClickPlay = { index, item -> viewModel.play(item) },
+ onClickPlayNext = { _, item -> viewModel.playNext(item) },
+ onClickAddToQueue = { index, item -> viewModel.addToQueue(item, index) },
+ onClickFavorite = { itemId, favorite -> viewModel.setFavorite(itemId, favorite) },
+ onClickAddPlaylist = { itemId ->
+ playlistViewModel.loadPlaylists(MediaType.AUDIO)
+ showPlaylistDialog.makePresent(itemId)
+ },
+ onClickRemoveFromQueue = {},
+ onClickDelete = { showDeleteDialog = it },
+ )
+ }
+
+ when (val loading = state.loading) {
+ is LoadingState.Error -> {
+ ErrorMessage(loading, modifier)
+ }
+
+ LoadingState.Loading,
+ LoadingState.Pending,
+ -> {
+ LoadingPage(modifier)
+ }
+
+ LoadingState.Success -> {
+ val artist = state.artist!!
+ val songFocusRequester = remember { FocusRequester() }
+ LaunchedEffect(Unit) {
+ if (position.row == SONG_ROW) {
+ songFocusRequester.tryRequestFocus()
+ } else {
+ focusRequesters.getOrNull(position.row)?.tryRequestFocus()
+ }
+ viewModel.refresh()
+ }
+ val bringIntoViewRequester = remember { BringIntoViewRequester() }
+ Box(modifier = modifier) {
+ LazyColumn(
+ contentPadding = PaddingValues(bottom = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(0.dp),
+ modifier =
+ Modifier
+ .fillMaxSize(),
+ ) {
+ item {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .bringIntoViewRequester(bringIntoViewRequester)
+ .padding(bottom = 16.dp),
+ ) {
+ ArtistHeader(
+ artist = artist,
+ imageUrl = state.imageUrl,
+ overviewOnClick = {},
+ bringIntoViewRequester = bringIntoViewRequester,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ MusicExpandableButtons(
+ actions =
+ remember {
+ MusicButtonActions(
+ onClickPlay = { shuffled ->
+ viewModel.play(artist, shuffled = shuffled)
+ },
+ onClickInstantMix = { viewModel.startInstantMix(artist.id) },
+ onClickFavorite = {
+ viewModel.setFavorite(
+ artist.id,
+ !artist.favorite,
+ )
+ },
+ onClickMore = {
+ moreDialog =
+ DialogParams(
+ fromLongClick = false,
+ title = artist.name ?: "",
+ items =
+ buildMoreDialogForMusic(
+ context = context,
+ actions = moreDialogActions,
+ item = artist,
+ index = 0,
+ canRemove = false,
+ canDelete =
+ viewModel.canDelete(
+ artist,
+ preferences.appPreferences,
+ ),
+ ),
+ )
+ },
+ )
+ },
+ favorite = artist.favorite,
+ buttonOnFocusChanged = {
+ if (it.isFocused) {
+ position = RowColumn(HEADER_ROW, 0)
+ scope.launch { bringIntoViewRequester.bringIntoView() }
+ }
+ },
+ modifier =
+ Modifier
+ .onFocusChanged {
+ if (it.hasFocus) scope.launch { bringIntoViewRequester.bringIntoView() }
+ }.focusRequester(focusRequesters[HEADER_ROW]),
+ )
+ }
+ }
+ if (state.topSongs.isNotEmpty()) {
+ item {
+ Text(
+ text = stringResource(R.string.popular_songs),
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onBackground,
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ }
+ itemsIndexed(state.topSongs) { index, song ->
+ Box(
+ modifier = Modifier.fillMaxWidth(),
+ contentAlignment = Alignment.Center,
+ ) {
+ SongListItem(
+ song = song,
+ onClick = {
+ position = RowColumn(SONG_ROW, index)
+ song?.let { viewModel.play(it) }
+ },
+ onLongClick = {
+ if (song != null) {
+ moreDialog =
+ DialogParams(
+ fromLongClick = true,
+ title = song.name ?: "",
+ items =
+ buildMoreDialogForMusic(
+ context = context,
+ actions = moreDialogActions,
+ item = song,
+ index = index,
+ canRemove = false,
+ canDelete =
+ viewModel.canDelete(
+ song,
+ preferences.appPreferences,
+ ),
+ ),
+ )
+ }
+ },
+ onClickMore = {
+ if (song != null) {
+ moreDialog =
+ DialogParams(
+ fromLongClick = false,
+ title = song.name ?: "",
+ items =
+ buildMoreDialogForMusic(
+ context = context,
+ actions = moreDialogActions,
+ item = song,
+ index = index,
+ canRemove = false,
+ canDelete =
+ viewModel.canDelete(
+ song,
+ preferences.appPreferences,
+ ),
+ ),
+ )
+ }
+ },
+ showArtist = false,
+ isPlaying = song != null && currentMusic.currentItemId == song.id,
+ isQueued = song != null && song.id in currentMusic.queuedIds,
+ modifier =
+ Modifier
+ .fillMaxWidth(.75f)
+ .ifElse(
+ position.row == SONG_ROW && position.column == index,
+ Modifier.focusRequester(songFocusRequester),
+ ),
+ )
+ }
+ }
+ }
+ item {
+ ItemRow(
+ title = stringResource(R.string.albums),
+ items = state.albums,
+ onClickItem = { index, album ->
+ position = RowColumn(ALBUM_ROW, index)
+ viewModel.navigationManager.navigateTo(album.destination())
+ },
+ onLongClickItem = { index, album ->
+ moreDialog =
+ DialogParams(
+ fromLongClick = true,
+ title = album.name ?: "",
+ items =
+ buildMoreDialogForMusic(
+ context = context,
+ actions = moreDialogActions,
+ item = album,
+ index = index,
+ canRemove = false,
+ canDelete =
+ viewModel.canDelete(
+ album,
+ preferences.appPreferences,
+ ),
+ ),
+ )
+ },
+ cardContent = { index: Int, album: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
+ BannerCardWithTitle(
+ title = album?.name,
+ subtitle = album?.data?.productionYear?.toString(),
+ item = album,
+ onClick = onClick,
+ onLongClick = onLongClick,
+ aspectRatio = AspectRatios.SQUARE,
+ )
+ },
+ modifier = Modifier.focusRequester(focusRequesters[ALBUM_ROW]),
+ )
+ }
+ if (state.musicVideos.isNotEmpty()) {
+ item {
+ ItemRow(
+ title = stringResource(R.string.music_videos),
+ items = state.musicVideos,
+ onClickItem = { index, item ->
+ position = RowColumn(MUSIC_VIDEO_ROW, index)
+ viewModel.navigationManager.navigateTo(item.destination())
+ },
+ onLongClickItem = { index, item ->
+ // TODO
+ },
+ cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
+ BannerCardWithTitle(
+ title = item?.name,
+ subtitle = item?.data?.productionYear?.toString(),
+ item = item,
+ onClick = onClick,
+ onLongClick = onLongClick,
+ aspectRatio = AspectRatios.WIDE,
+ played = item?.played ?: false,
+ playPercent = item?.data?.userData?.playedPercentage ?: 0.0,
+ favorite = item?.favorite ?: false,
+ modifier = mod,
+ )
+ },
+ modifier = Modifier.focusRequester(focusRequesters[MUSIC_VIDEO_ROW]),
+ )
+ }
+ }
+ if (state.similar.isNotEmpty()) {
+ item {
+ ItemRow(
+ title = stringResource(R.string.more_like_this),
+ items = state.similar,
+ onClickItem = { index, item ->
+ position = RowColumn(SIMILAR_ROW, index)
+ viewModel.navigationManager.navigateTo(item.destination())
+ },
+ onLongClickItem = { index, item ->
+ moreDialog =
+ DialogParams(
+ fromLongClick = true,
+ title = item.name ?: "",
+ items =
+ buildMoreDialogForMusic(
+ context = context,
+ actions = moreDialogActions,
+ item = item,
+ index = index,
+ canRemove = false,
+ canDelete =
+ viewModel.canDelete(
+ item,
+ preferences.appPreferences,
+ ),
+ ),
+ )
+ },
+ cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
+ BannerCardWithTitle(
+ title = item?.name,
+ subtitle = item?.data?.productionYear?.toString(),
+ item = item,
+ onClick = onClick,
+ onLongClick = onLongClick,
+ aspectRatio = AspectRatios.SQUARE,
+ modifier = mod,
+ )
+ },
+ modifier = Modifier.focusRequester(focusRequesters[SIMILAR_ROW]),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ moreDialog?.let { params ->
+ DialogPopup(
+ showDialog = true,
+ title = params.title,
+ dialogItems = params.items,
+ onDismissRequest = { moreDialog = null },
+ dismissOnClick = true,
+ waitToLoad = params.fromLongClick,
+ )
+ }
+ showPlaylistDialog.compose { itemId ->
+ PlaylistDialog(
+ title = stringResource(R.string.add_to_playlist),
+ state = playlistState,
+ onDismissRequest = { showPlaylistDialog.makeAbsent() },
+ onClick = {
+ playlistViewModel.addToPlaylist(it.id, itemId)
+ showPlaylistDialog.makeAbsent()
+ },
+ createEnabled = true,
+ onCreatePlaylist = {
+ playlistViewModel.createPlaylistAndAddItem(it, itemId)
+ showPlaylistDialog.makeAbsent()
+ },
+ elevation = 3.dp,
+ )
+ }
+ showDeleteDialog?.let { item ->
+ ConfirmDeleteDialog(
+ itemTitle = item.title ?: "",
+ onCancel = { showDeleteDialog = null },
+ onConfirm = {
+ viewModel.deleteItem(item)
+ focusRequesters.getOrNull(position.row)?.tryRequestFocus()
+ showDeleteDialog = null
+ },
+ )
+ }
+}
+
+@Composable
+fun ArtistHeader(
+ artist: BaseItem,
+ imageUrl: String?,
+ overviewOnClick: () -> Unit,
+ bringIntoViewRequester: BringIntoViewRequester,
+ modifier: Modifier,
+) {
+ val scope = rememberCoroutineScope()
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = modifier.padding(top = 32.dp),
+ ) {
+ AsyncImage(
+ model = imageUrl,
+ contentDescription = null,
+ contentScale = ContentScale.FillWidth,
+ modifier =
+ Modifier
+ .fillMaxWidth(.20f)
+ .clip(RoundedCornerShape(16.dp)),
+ )
+ Column(
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ // Artist
+ Text(
+ text = artist.artistsString ?: "",
+ color = MaterialTheme.colorScheme.onBackground,
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier =
+ Modifier
+ .fillMaxWidth(.75f)
+ .padding(start = 8.dp),
+ )
+ Text(
+ text = artist.name ?: "",
+ color = MaterialTheme.colorScheme.onBackground,
+ style = MaterialTheme.typography.headlineSmall,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier =
+ Modifier
+ .fillMaxWidth(.75f)
+ .padding(start = 8.dp),
+ )
+
+ Column(
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ modifier = Modifier.fillMaxWidth(.60f),
+ ) {
+ QuickDetails(
+ artist.ui.quickDetails,
+ null,
+ Modifier.padding(start = 8.dp),
+ )
+
+ artist.data.genres?.letNotEmpty {
+ GenreText(it, Modifier.padding(start = 8.dp))
+ }
+
+ // Description
+ artist.data.overview?.let { overview ->
+ OverviewText(
+ overview = overview,
+ maxLines = 3,
+ onClick = overviewOnClick,
+ textBoxHeight = Dp.Unspecified,
+ modifier =
+ Modifier.onFocusChanged {
+ if (it.isFocused) {
+ scope.launch(ExceptionHandler()) {
+ bringIntoViewRequester.bringIntoView()
+ }
+ }
+ },
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/BarVisualizer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/BarVisualizer.kt
new file mode 100644
index 00000000..d5ea2037
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/BarVisualizer.kt
@@ -0,0 +1,54 @@
+package com.github.damontecres.wholphin.ui.detail.music
+
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.MaterialTheme
+
+@Composable
+fun BarVisualizer(
+ data: IntArray,
+ modifier: Modifier,
+) {
+ var size by remember { mutableStateOf(IntSize.Zero) }
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = modifier.onSizeChanged { size = it },
+ ) {
+ val size = with(LocalDensity.current) { DpSize(size.width.toDp(), size.height.toDp()) }
+ val padding = 1.dp
+ val width = size.width / data.size
+
+ data.forEachIndexed { index, data ->
+ val height by animateDpAsState(
+ targetValue = size.height * data / 256f,
+ animationSpec = tween(easing = LinearEasing),
+ )
+ Box(
+ Modifier
+ .height(height)
+ .width(width)
+ .padding(start = if (index == 0) 0.dp else padding)
+ .background(MaterialTheme.colorScheme.border),
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/LyricsContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/LyricsContent.kt
new file mode 100644
index 00000000..6ccea46e
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/LyricsContent.kt
@@ -0,0 +1,145 @@
+package com.github.damontecres.wholphin.ui.detail.music
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsFocusedAsState
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.relocation.BringIntoViewRequester
+import androidx.compose.foundation.relocation.bringIntoViewRequester
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusProperties
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.ClickableSurfaceDefaults
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Surface
+import androidx.tv.material3.Text
+import com.github.damontecres.wholphin.ui.ifElse
+import com.github.damontecres.wholphin.ui.tryRequestFocus
+import com.github.damontecres.wholphin.ui.util.rememberDelayedNestedScroll
+import org.jellyfin.sdk.model.api.LyricDto
+import org.jellyfin.sdk.model.api.LyricLine
+
+@Composable
+fun LyricsContent(
+ lyricsHaveFocus: Boolean,
+ lyrics: LyricDto?,
+ currentLyricPosition: Int?,
+ onClick: (LyricLine) -> Unit,
+ onFocusLyrics: (Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val focusRequesters =
+ remember(lyrics) { List(lyrics?.lyrics.orEmpty().size) { FocusRequester() } }
+ val listState = rememberLazyListState(currentLyricPosition ?: 0)
+
+ val scrollConnection = rememberDelayedNestedScroll(yDelay = .66f)
+
+ val bringIntoViewRequester = remember { BringIntoViewRequester() }
+ if (!lyricsHaveFocus) {
+ LaunchedEffect(currentLyricPosition) {
+ if (currentLyricPosition != null) {
+ listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index?.let {
+ if (currentLyricPosition !in listState.firstVisibleItemIndex..it) {
+ listState.animateScrollToItem(currentLyricPosition)
+ }
+ }
+ bringIntoViewRequester.bringIntoView()
+ }
+ }
+ }
+ Column(
+ modifier
+ .nestedScroll(scrollConnection),
+ ) {
+ LazyColumn(
+ state = listState,
+ contentPadding = PaddingValues(),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .focusProperties {
+ onEnter = {
+ if (currentLyricPosition != null) {
+ currentLyricPosition.let {
+ focusRequesters
+ .getOrNull(currentLyricPosition)
+ ?.tryRequestFocus()
+ }
+ } else {
+ focusRequesters.getOrNull(0)?.tryRequestFocus()
+ }
+ }
+ }.onFocusChanged {
+ onFocusLyrics.invoke(it.hasFocus)
+ },
+ ) {
+ if (lyrics?.lyrics?.isNotEmpty() == true) {
+ itemsIndexed(lyrics.lyrics) { index, lyric ->
+ val interactionSource = remember { MutableInteractionSource() }
+ val focused by interactionSource.collectIsFocusedAsState()
+ val color by animateColorAsState(
+ if (index == currentLyricPosition || currentLyricPosition == null) {
+ MaterialTheme.colorScheme.onSurface
+ } else {
+ MaterialTheme.colorScheme.onSurface.copy(alpha = .4f)
+ },
+ animationSpec = tween(durationMillis = 500, easing = LinearEasing),
+ )
+ Surface(
+ onClick = { onClick.invoke(lyric) },
+ interactionSource = interactionSource,
+ colors =
+ ClickableSurfaceDefaults.colors(
+ containerColor = Color.Transparent,
+ focusedContainerColor = MaterialTheme.colorScheme.border.copy(alpha = .33f),
+ ),
+ shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(8.dp)),
+ scale =
+ ClickableSurfaceDefaults.scale(
+ focusedScale = 1f,
+ pressedScale = .9f,
+ ),
+ modifier =
+ Modifier
+ .focusRequester(focusRequesters[index]),
+ ) {
+ val text =
+ remember(lyric.text) { lyric.text.ifBlank { " " } }
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyLarge,
+ color = if (focused) MaterialTheme.colorScheme.onSurface else color,
+ modifier =
+ Modifier
+ .padding(8.dp)
+ .ifElse(
+ index == currentLyricPosition,
+ Modifier.bringIntoViewRequester(bringIntoViewRequester),
+ ),
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicExpandableButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicExpandableButtons.kt
new file mode 100644
index 00000000..78a5603b
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicExpandableButtons.kt
@@ -0,0 +1,95 @@
+package com.github.damontecres.wholphin.ui.detail.music
+
+import androidx.compose.foundation.focusGroup
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.FocusState
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.focusRestorer
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
+import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
+import kotlin.time.Duration
+
+@Composable
+fun MusicExpandableButtons(
+ actions: MusicButtonActions,
+ favorite: Boolean,
+ buttonOnFocusChanged: (FocusState) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val firstFocus = remember { FocusRequester() }
+ LazyRow(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ contentPadding = PaddingValues(8.dp),
+ modifier =
+ modifier
+ .focusGroup()
+ .focusRestorer(firstFocus),
+ ) {
+ item("play") {
+ ExpandablePlayButton(
+ title = R.string.play,
+ resume = Duration.ZERO,
+ icon = Icons.Default.PlayArrow,
+ onClick = { actions.onClickPlay.invoke(false) },
+ modifier =
+ Modifier
+ .focusRequester(firstFocus)
+ .onFocusChanged(buttonOnFocusChanged),
+ )
+ }
+ item("shuffle") {
+ ExpandableFaButton(
+ title = R.string.shuffle,
+ iconStringRes = R.string.fa_shuffle,
+ onClick = { actions.onClickPlay.invoke(true) },
+ modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
+ )
+ }
+ item("instant_mix") {
+ ExpandableFaButton(
+ title = R.string.instant_mix,
+ iconStringRes = R.string.fa_compass,
+ onClick = actions.onClickInstantMix,
+ modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
+ )
+ }
+ item("favorite") {
+ ExpandableFaButton(
+ title = if (favorite) R.string.remove_favorite else R.string.add_favorite,
+ iconStringRes = R.string.fa_heart,
+ onClick = actions.onClickFavorite,
+ iconColor = if (favorite) Color.Red else Color.Unspecified,
+ modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
+ )
+ }
+ item("more") {
+ ExpandablePlayButton(
+ title = R.string.more,
+ resume = Duration.ZERO,
+ icon = Icons.Default.MoreVert,
+ onClick = { actions.onClickMore.invoke() },
+ modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
+ )
+ }
+ }
+}
+
+data class MusicButtonActions(
+ val onClickPlay: (shuffle: Boolean) -> Unit,
+ val onClickInstantMix: () -> Unit,
+ val onClickFavorite: () -> Unit,
+ val onClickMore: () -> Unit,
+)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicPreference.kt
new file mode 100644
index 00000000..1a519998
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicPreference.kt
@@ -0,0 +1,42 @@
+package com.github.damontecres.wholphin.ui.detail.music
+
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.preferences.AppPreferences
+import com.github.damontecres.wholphin.preferences.AppSwitchPreference
+import com.github.damontecres.wholphin.preferences.updateMusicPreferences
+
+fun getMusicPreferences() =
+ listOf(
+ AppSwitchPreference(
+ title = R.string.show_album_cover,
+ defaultValue = false,
+ getter = { it.musicPreferences.showAlbumArt },
+ setter = { prefs, value ->
+ prefs.updateMusicPreferences { showAlbumArt = value }
+ },
+ ),
+ AppSwitchPreference(
+ title = R.string.show_visualizer,
+ defaultValue = false,
+ getter = { it.musicPreferences.showVisualizer },
+ setter = { prefs, value ->
+ prefs.updateMusicPreferences { showVisualizer = value }
+ },
+ ),
+ AppSwitchPreference(
+ title = R.string.show_backdrop,
+ defaultValue = false,
+ getter = { it.musicPreferences.showBackdrop },
+ setter = { prefs, value ->
+ prefs.updateMusicPreferences { showBackdrop = value }
+ },
+ ),
+ AppSwitchPreference(
+ title = R.string.show_lyrics,
+ defaultValue = false,
+ getter = { it.musicPreferences.showLyrics },
+ setter = { prefs, value ->
+ prefs.updateMusicPreferences { showLyrics = value }
+ },
+ ),
+ )
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewModel.kt
new file mode 100644
index 00000000..812927f1
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewModel.kt
@@ -0,0 +1,149 @@
+package com.github.damontecres.wholphin.ui.detail.music
+
+import android.content.Context
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.preferences.AppPreferences
+import com.github.damontecres.wholphin.services.MediaManagementService
+import com.github.damontecres.wholphin.services.MusicService
+import com.github.damontecres.wholphin.services.NavigationManager
+import com.github.damontecres.wholphin.services.deleteItem
+import com.github.damontecres.wholphin.ui.launchDefault
+import com.github.damontecres.wholphin.ui.launchIO
+import com.github.damontecres.wholphin.ui.nav.Destination
+import com.github.damontecres.wholphin.util.ApiRequestPager
+import com.github.damontecres.wholphin.util.BlockingList
+import kotlinx.coroutines.delay
+import org.jellyfin.sdk.api.client.ApiClient
+import org.jellyfin.sdk.model.api.BaseItemKind
+import timber.log.Timber
+import java.util.UUID
+
+abstract class MusicViewModel(
+ internal val itemId: UUID,
+ internal val context: Context,
+ internal val api: ApiClient,
+ internal val musicService: MusicService,
+ internal val navigationManager: NavigationManager,
+ internal val mediaManagementService: MediaManagementService,
+) : ViewModel() {
+ fun play(
+ pager: ApiRequestPager<*>,
+ startIndex: Int = 0,
+ shuffled: Boolean = false,
+ ) {
+ viewModelScope.launchIO {
+ musicService.setQueue(pager, startIndex, shuffled)
+ }
+ }
+
+ fun play(
+ item: BaseItem,
+ startIndex: Int = 0,
+ shuffled: Boolean = false,
+ ) {
+ viewModelScope.launchIO {
+ Timber.v("Playing %s %s", item.type, item.id)
+ when (item.type) {
+ BaseItemKind.AUDIO -> {
+ musicService.setQueue(listOf(item), shuffled)
+ }
+
+ BaseItemKind.MUSIC_ALBUM -> {
+ val pager = getPagerForAlbum(api, item.id)
+ musicService.setQueue(pager, startIndex, shuffled)
+ }
+
+ BaseItemKind.MUSIC_ARTIST -> {
+ val pager = getPagerForArtist(api, item.id)
+ musicService.setQueue(pager, startIndex, shuffled)
+ }
+
+ BaseItemKind.PLAYLIST -> {
+ val pager = getPagerForPlaylist(api, item.id)
+ musicService.setQueue(pager, startIndex, shuffled)
+ }
+
+ else -> {
+ Timber.w("Unknown item type to play for music: %s", item.type)
+ }
+ }
+ }
+ }
+
+ fun playNext(song: BaseItem) {
+ viewModelScope.launchDefault {
+ musicService.playNext(song)
+ }
+ }
+
+ fun addToQueue(
+ item: BaseItem,
+ index: Int,
+ ) = addToQueue(api, musicService, item, index)
+
+ fun startInstantMix(itemId: UUID) {
+ viewModelScope.launchIO {
+ Timber.v("Starting instant mix for %s", itemId)
+ musicService.startInstantMix(itemId)
+ }
+ viewModelScope.launchDefault {
+ // TODO better way to wait for query above to start
+ delay(250)
+ navigationManager.navigateTo(Destination.NowPlaying)
+ }
+ }
+
+ fun canDelete(
+ item: BaseItem,
+ appPreferences: AppPreferences,
+ ): Boolean = mediaManagementService.canDelete(item, appPreferences)
+
+ fun deleteItem(item: BaseItem) {
+ deleteItem(context, mediaManagementService, item) {
+ if (item.id == itemId) {
+ navigationManager.goBack()
+ } else {
+ init()
+ }
+ }
+ }
+
+ internal abstract fun init()
+}
+
+fun ViewModel.addToQueue(
+ api: ApiClient,
+ musicService: MusicService,
+ item: BaseItem,
+ index: Int,
+) {
+ viewModelScope.launchIO {
+ Timber.v("addToQueue %s %s", item.type, item.id)
+ when (item.type) {
+ BaseItemKind.AUDIO -> {
+ musicService.addAllToQueue(BlockingList.of(listOf(item)), 0)
+ }
+
+ BaseItemKind.MUSIC_ALBUM -> {
+ val pager = getPagerForAlbum(api, item.id)
+ musicService.addAllToQueue(pager, 0)
+ }
+
+ BaseItemKind.MUSIC_ARTIST -> {
+ val pager = getPagerForArtist(api, item.id)
+ musicService.addAllToQueue(pager, 0)
+ }
+
+ BaseItemKind.PLAYLIST -> {
+ val pager = getPagerForPlaylist(api, item.id)
+ musicService.addAllToQueue(pager, 0)
+ }
+
+ else -> {
+ Timber.w("Unknown item type to queue for music: %s", item.type)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewOptionsDialog.kt
new file mode 100644
index 00000000..cf9f9c7c
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewOptionsDialog.kt
@@ -0,0 +1,133 @@
+package com.github.damontecres.wholphin.ui.detail.music
+
+import android.view.Gravity
+import androidx.compose.foundation.background
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import androidx.compose.ui.window.DialogWindowProvider
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import androidx.tv.material3.surfaceColorAtElevation
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.preferences.AppPreference
+import com.github.damontecres.wholphin.preferences.AppPreferences
+import com.github.damontecres.wholphin.ui.preferences.ComposablePreference
+import com.github.damontecres.wholphin.ui.tryRequestFocus
+
+@Composable
+fun MusicViewOptionsDialog(
+ appPreferences: AppPreferences,
+ onDismissRequest: () -> Unit,
+ onViewOptionsChange: (AppPreferences) -> Unit,
+ onEnableVisualizer: () -> Unit,
+) {
+ val context = LocalContext.current
+
+ val focusRequester = remember { FocusRequester() }
+ LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
+ val columnState = rememberLazyListState()
+ val items = remember { getMusicPreferences() }
+
+ Dialog(
+ onDismissRequest = onDismissRequest,
+ properties =
+ DialogProperties(
+ usePlatformDefaultWidth = false,
+ ),
+ ) {
+ val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider
+ dialogWindowProvider?.window?.let { window ->
+ window.setGravity(Gravity.END)
+ window.setDimAmount(0f)
+ }
+
+ Column {
+ Text(
+ text = stringResource(R.string.view_options),
+ style = MaterialTheme.typography.titleMedium,
+ )
+ }
+ LazyColumn(
+ state = columnState,
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(0.dp),
+ modifier =
+ Modifier
+ .width(256.dp)
+ .heightIn(max = 380.dp)
+ .focusRequester(focusRequester)
+ .background(
+ MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp),
+ shape = RoundedCornerShape(8.dp),
+ ),
+ ) {
+ items(items) { pref ->
+ pref as AppPreference
+ val interactionSource = remember { MutableInteractionSource() }
+ val value = pref.getter.invoke(appPreferences)
+ // Using title is a bit hacky
+ when (pref.title) {
+ R.string.show_visualizer -> {
+ ComposablePreference(
+ preference = pref,
+ value = value,
+ onNavigate = {},
+ onValueChange = { newValue ->
+ newValue as Boolean
+ if (newValue) {
+ onEnableVisualizer.invoke()
+ } else {
+ onViewOptionsChange.invoke(
+ pref.setter(
+ appPreferences,
+ newValue,
+ ),
+ )
+ }
+ },
+ interactionSource = interactionSource,
+ modifier = Modifier,
+ onClickPreference = { pref ->
+ },
+ )
+ }
+
+ else -> {
+ ComposablePreference(
+ preference = pref,
+ value = value,
+ onNavigate = {},
+ onValueChange = { newValue ->
+ onViewOptionsChange.invoke(pref.setter(appPreferences, newValue))
+ },
+ interactionSource = interactionSource,
+ modifier = Modifier,
+ onClickPreference = { pref ->
+ },
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingButtons.kt
new file mode 100644
index 00000000..44f08012
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingButtons.kt
@@ -0,0 +1,320 @@
+package com.github.damontecres.wholphin.ui.detail.music
+
+import androidx.annotation.OptIn
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.focusGroup
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsFocusedAsState
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.ui.compose.state.rememberNextButtonState
+import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
+import androidx.media3.ui.compose.state.rememberPreviousButtonState
+import androidx.media3.ui.compose.state.rememberRepeatButtonState
+import androidx.media3.ui.compose.state.rememberShuffleButtonState
+import androidx.tv.material3.Border
+import androidx.tv.material3.ClickableSurfaceDefaults
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.ui.AppColors
+import com.github.damontecres.wholphin.ui.FontAwesome
+import com.github.damontecres.wholphin.ui.PreviewTvSpec
+import com.github.damontecres.wholphin.ui.components.Button
+import com.github.damontecres.wholphin.ui.playback.ControllerViewState
+import com.github.damontecres.wholphin.ui.playback.PlaybackAction
+import com.github.damontecres.wholphin.ui.playback.PlaybackButton
+import com.github.damontecres.wholphin.ui.playback.PlaybackButtons
+import com.github.damontecres.wholphin.ui.playback.PlaybackDialogType
+import com.github.damontecres.wholphin.ui.playback.buttonSpacing
+import com.github.damontecres.wholphin.ui.theme.PreviewInteractionSource
+import com.github.damontecres.wholphin.ui.theme.WholphinTheme
+import kotlin.time.Duration.Companion.seconds
+
+@OptIn(UnstableApi::class)
+@Composable
+fun NowPlayingButtons(
+ player: Player,
+ controllerViewState: ControllerViewState,
+ initialFocusRequester: FocusRequester,
+ onClickMore: () -> Unit,
+ onClickStop: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val playPauseState = rememberPlayPauseButtonState(player)
+ val previousState = rememberPreviousButtonState(player)
+ val nextState = rememberNextButtonState(player)
+ val shuffleState = rememberShuffleButtonState(player)
+ val repeatState = rememberRepeatButtonState(player)
+
+ val onControllerInteraction = remember { { controllerViewState.pulseControls() } }
+ Box(
+ modifier = modifier.focusGroup(),
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(buttonSpacing),
+ modifier = Modifier.align(Alignment.CenterStart),
+ ) {
+ PlaybackButton(
+ iconRes = R.drawable.baseline_more_vert_96,
+ onClick = {
+ onControllerInteraction.invoke()
+ onClickMore.invoke()
+ },
+ enabled = true,
+ onControllerInteraction = onControllerInteraction,
+ modifier = Modifier,
+ )
+ PlaybackButton(
+ iconRes = R.drawable.baseline_stop_24,
+ onClick = {
+ onClickStop.invoke()
+ },
+ enabled = true,
+ onControllerInteraction = onControllerInteraction,
+ modifier = Modifier,
+ )
+ }
+ PlaybackButtons(
+ player = player,
+ initialFocusRequester = initialFocusRequester,
+ onControllerInteraction = onControllerInteraction,
+ onPlaybackActionClick = {
+ when (it) {
+ PlaybackAction.Next -> {
+ nextState.onClick()
+ }
+
+ PlaybackAction.Previous -> {
+ previousState.onClick()
+ }
+
+ is PlaybackAction.ToggleCaptions -> {
+ TODO()
+ }
+
+ else -> {}
+ }
+ },
+ showPlay = playPauseState.showPlay,
+ previousEnabled = previousState.isEnabled,
+ nextEnabled = nextState.isEnabled,
+ seekBack = 10.seconds,
+ skipBackOnResume = null,
+ seekForward = 30.seconds, // TODO
+ modifier = Modifier.align(Alignment.Center),
+ )
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(buttonSpacing),
+ modifier = Modifier.align(Alignment.CenterEnd),
+ ) {
+ ShuffleButton(
+ active = shuffleState.shuffleOn,
+ enabled = shuffleState.isEnabled,
+ onClick = {
+ shuffleState.onClick()
+ },
+ onControllerInteraction = onControllerInteraction,
+ )
+ RepeatButton(
+ repeatMode = repeatState.repeatModeState,
+ enabled = repeatState.isEnabled,
+ onClick = {
+ repeatState.onClick()
+ },
+ onControllerInteraction = onControllerInteraction,
+ )
+ }
+ }
+}
+
+@Composable
+fun ShuffleButton(
+ active: Boolean,
+ onClick: () -> Unit,
+ onControllerInteraction: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+) {
+ val selectedColor = MaterialTheme.colorScheme.border
+ val focused by interactionSource.collectIsFocusedAsState()
+ Button(
+ enabled = enabled,
+ onClick = onClick,
+// shape = ButtonDefaults.shape(CircleShape),
+ colors =
+ ClickableSurfaceDefaults.colors(
+ containerColor = AppColors.TransparentBlack25,
+ focusedContainerColor = selectedColor,
+ ),
+ contentPadding = PaddingValues(4.dp),
+ interactionSource = interactionSource,
+ modifier =
+ modifier
+ .size(36.dp, 36.dp)
+ .onFocusChanged { onControllerInteraction.invoke() },
+ ) {
+ Text(
+ text = stringResource(R.string.fa_shuffle),
+ fontSize = 18.sp,
+ fontFamily = FontAwesome,
+ textAlign = TextAlign.Center,
+ color =
+ when {
+ focused && active -> MaterialTheme.colorScheme.onSurface
+ focused && !active -> MaterialTheme.colorScheme.surface
+ !focused && active -> MaterialTheme.colorScheme.onSurface
+ else -> MaterialTheme.colorScheme.onSurface.copy(alpha = .5f)
+ },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .align(Alignment.CenterVertically),
+ )
+ }
+}
+
+@Composable
+fun RepeatButton(
+ repeatMode: Int,
+ onClick: () -> Unit,
+ onControllerInteraction: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+) {
+ val selectedColor = MaterialTheme.colorScheme.border
+ val focused by interactionSource.collectIsFocusedAsState()
+ Button(
+ enabled = enabled,
+ onClick = onClick,
+// shape = ButtonDefaults.shape(CircleShape),
+ colors =
+ ClickableSurfaceDefaults.colors(
+ containerColor = AppColors.TransparentBlack25,
+ focusedContainerColor = selectedColor,
+ ),
+ contentPadding = PaddingValues(4.dp),
+ interactionSource = interactionSource,
+ modifier =
+ modifier
+ .size(36.dp, 36.dp)
+ .onFocusChanged { onControllerInteraction.invoke() },
+ ) {
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .align(Alignment.CenterVertically),
+ ) {
+ Text(
+ text = stringResource(R.string.fa_repeat),
+ fontSize = 18.sp,
+ fontFamily = FontAwesome,
+ textAlign = TextAlign.Center,
+ color =
+ if (focused) {
+ when (repeatMode) {
+ Player.REPEAT_MODE_ALL -> MaterialTheme.colorScheme.onSurface
+ Player.REPEAT_MODE_ONE -> MaterialTheme.colorScheme.onSurface
+ else -> MaterialTheme.colorScheme.surface
+ }
+ } else {
+ when (repeatMode) {
+ Player.REPEAT_MODE_ALL -> MaterialTheme.colorScheme.onSurface
+ Player.REPEAT_MODE_ONE -> MaterialTheme.colorScheme.onSurface
+ else -> MaterialTheme.colorScheme.onSurface.copy(alpha = .5f)
+ }
+ },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .align(Alignment.Center),
+ )
+ if (repeatMode == Player.REPEAT_MODE_ONE) {
+ Text(
+ text = "1",
+ fontSize = 10.sp,
+ color = MaterialTheme.colorScheme.surface,
+ modifier =
+ Modifier
+ .offset(x = 4.dp)
+ .background(
+ color = MaterialTheme.colorScheme.onSurface,
+ shape = RoundedCornerShape(1.dp),
+ ).align(Alignment.BottomStart),
+ )
+ }
+ }
+ }
+}
+
+@PreviewTvSpec
+@Composable
+fun ShuffleButtonPreview() {
+ val source = remember { PreviewInteractionSource() }
+ WholphinTheme {
+ Column {
+ Row {
+ RepeatButton(
+ repeatMode = Player.REPEAT_MODE_OFF,
+ onClick = {},
+ onControllerInteraction = {},
+ )
+ RepeatButton(
+ repeatMode = Player.REPEAT_MODE_ALL,
+ onClick = {},
+ onControllerInteraction = {},
+ )
+ RepeatButton(
+ repeatMode = Player.REPEAT_MODE_ONE,
+ onClick = {},
+ onControllerInteraction = {},
+ )
+ }
+ Row {
+ RepeatButton(
+ repeatMode = Player.REPEAT_MODE_OFF,
+ onClick = {},
+ onControllerInteraction = {},
+ interactionSource = source,
+ )
+ RepeatButton(
+ repeatMode = Player.REPEAT_MODE_ALL,
+ onClick = {},
+ onControllerInteraction = {},
+ interactionSource = source,
+ )
+ RepeatButton(
+ repeatMode = Player.REPEAT_MODE_ONE,
+ onClick = {},
+ onControllerInteraction = {},
+ interactionSource = source,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt
new file mode 100644
index 00000000..d76e560e
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt
@@ -0,0 +1,229 @@
+package com.github.damontecres.wholphin.ui.detail.music
+
+import androidx.activity.compose.BackHandler
+import androidx.annotation.OptIn
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.background
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.wrapContentWidth
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusDirection
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusProperties
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
+import androidx.tv.material3.Icon
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.data.model.AudioItem
+import com.github.damontecres.wholphin.ui.components.Button
+import com.github.damontecres.wholphin.ui.ifElse
+import com.github.damontecres.wholphin.ui.main.settings.MoveDirection
+import com.github.damontecres.wholphin.ui.playback.ControllerViewState
+import com.github.damontecres.wholphin.ui.playback.SeekBar
+import com.github.damontecres.wholphin.ui.preferences.MoveButton
+import com.github.damontecres.wholphin.ui.roundSeconds
+import com.github.damontecres.wholphin.ui.tryRequestFocus
+import kotlinx.coroutines.launch
+import kotlin.time.Duration
+
+@OptIn(UnstableApi::class)
+@Composable
+fun NowPlayingOverlay(
+ state: NowPlayingState,
+ player: Player,
+ current: AudioItem?,
+ queue: List,
+ controllerViewState: ControllerViewState,
+ onClickSong: (Int, AudioItem) -> Unit,
+ onLongClickSong: (Int, AudioItem) -> Unit,
+ onClickMore: () -> Unit,
+ onMoveQueue: (Int, MoveDirection) -> Unit,
+ onClickMoreItem: (Int, AudioItem) -> Unit,
+ onClickStop: () -> Unit,
+ lyricsFocusRequester: FocusRequester,
+ modifier: Modifier = Modifier,
+) {
+ val scope = rememberCoroutineScope()
+ val focusRequester = remember { FocusRequester() }
+ LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
+
+ var queueHasFocus by remember { mutableStateOf(false) }
+ val height by animateFloatAsState(
+ if (queueHasFocus) {
+ 1f
+ } else {
+ .33f
+ },
+ animationSpec = tween(durationMillis = 500),
+ )
+ val listState = rememberLazyListState()
+ var showButtons by remember { mutableStateOf(true) }
+
+ val firstFocusRequester = remember { FocusRequester() }
+ BackHandler(!showButtons) {
+ scope.launch {
+ listState.animateScrollToItem(0)
+ firstFocusRequester.tryRequestFocus()
+ }
+ }
+
+ Column(
+ modifier =
+ modifier
+ .padding(16.dp)
+ .fillMaxHeight(height),
+ ) {
+ SeekBar(
+ player = player,
+ controllerViewState = controllerViewState,
+ onSeekProgress = {},
+ interactionSource = remember { MutableInteractionSource() },
+ isEnabled = false,
+ intervals = 0,
+ seekBack = Duration.ZERO,
+ seekForward = Duration.ZERO,
+ modifier =
+ Modifier
+ .align(Alignment.CenterHorizontally)
+ .padding(top = 8.dp)
+ .fillMaxWidth(.95f),
+ )
+ AnimatedVisibility(
+ visible = showButtons,
+ enter = expandVertically(),
+ exit = shrinkVertically(),
+ modifier =
+ Modifier
+ .align(Alignment.CenterHorizontally),
+ ) {
+ NowPlayingButtons(
+ player = player,
+ controllerViewState = controllerViewState,
+ initialFocusRequester = focusRequester,
+ onClickMore = onClickMore,
+ onClickStop = onClickStop,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .align(Alignment.CenterHorizontally),
+ )
+ }
+ if (queue.isEmpty()) {
+ Text("No items")
+ } else {
+ Text(
+ text = stringResource(R.string.queue),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(vertical = 8.dp),
+ )
+ LazyColumn(
+ state = listState,
+ contentPadding = PaddingValues(bottom = 16.dp, start = 16.dp, end = 16.dp),
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .onFocusChanged {
+ queueHasFocus = it.hasFocus
+ }.focusProperties {
+ onExit = {
+ if (requestedFocusDirection == FocusDirection.Up) focusRequester.requestFocus()
+ }
+ },
+ ) {
+ itemsIndexed(queue, key = { _, song -> song.key }) { index, song ->
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .background(
+ color = MaterialTheme.colorScheme.surface.copy(alpha = .5f),
+ shape = RoundedCornerShape(8.dp),
+ ).padding(end = 8.dp)
+ .onFocusChanged {
+ if (it.hasFocus) showButtons = index < 3
+ controllerViewState.pulseControls()
+ }.animateItem(),
+ ) {
+ SongListItem(
+ title = song.title,
+ artist = song.artistNames,
+ indexNumber = index + 1,
+ runtime = song.runtime?.roundSeconds,
+ showArtist = true,
+ isPlaying = current?.id == song.id,
+ onClick = { onClickSong.invoke(index, song) },
+ onLongClick = { onLongClickSong.invoke(index, song) },
+ modifier =
+ Modifier
+ .weight(1f)
+ .ifElse(
+ index == 0,
+ Modifier.focusRequester(firstFocusRequester),
+ ),
+ )
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.wrapContentWidth(),
+ ) {
+ MoveButton(
+ icon = R.string.fa_caret_up,
+ enabled = index > 0,
+ onClick = { onMoveQueue.invoke(index, MoveDirection.UP) },
+ )
+ MoveButton(
+ icon = R.string.fa_caret_down,
+ enabled = index < queue.lastIndex,
+ onClick = { onMoveQueue.invoke(index, MoveDirection.DOWN) },
+ )
+ Button(
+ onClick = { onClickMoreItem.invoke(index, song) },
+ enabled = true,
+ modifier = Modifier.size(32.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Default.MoreVert,
+ contentDescription = stringResource(R.string.more),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt
new file mode 100644
index 00000000..1f253b82
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt
@@ -0,0 +1,480 @@
+package com.github.damontecres.wholphin.ui.detail.music
+
+import android.Manifest
+import android.view.Gravity
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.annotation.OptIn
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.expandHorizontally
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkHorizontally
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.wrapContentWidth
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.media3.common.util.UnstableApi
+import androidx.tv.material3.Button
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import coil3.compose.AsyncImage
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.data.model.AudioItem
+import com.github.damontecres.wholphin.preferences.AppPreferences
+import com.github.damontecres.wholphin.preferences.BackdropStyle
+import com.github.damontecres.wholphin.preferences.UserPreferences
+import com.github.damontecres.wholphin.services.rememberQueue
+import com.github.damontecres.wholphin.ui.components.BasicDialog
+import com.github.damontecres.wholphin.ui.components.DialogParams
+import com.github.damontecres.wholphin.ui.components.DialogPopup
+import com.github.damontecres.wholphin.ui.components.LoadingPage
+import com.github.damontecres.wholphin.ui.findActivity
+import com.github.damontecres.wholphin.ui.nav.Backdrop
+import com.github.damontecres.wholphin.ui.playback.BottomDialog
+import com.github.damontecres.wholphin.ui.playback.BottomDialogItem
+import com.github.damontecres.wholphin.ui.playback.PlaybackKeyHandler
+import com.github.damontecres.wholphin.ui.playback.isUp
+import com.github.damontecres.wholphin.ui.tryRequestFocus
+import com.github.damontecres.wholphin.util.LoadingState
+import org.jellyfin.sdk.model.extensions.ticks
+import kotlin.time.Duration.Companion.seconds
+
+@OptIn(UnstableApi::class)
+@Composable
+fun NowPlayingPage(
+ modifier: Modifier = Modifier,
+ viewModel: NowPlayingViewModel =
+ hiltViewModel(
+ creationCallback = { it.create() },
+ ),
+) {
+ val context = LocalContext.current
+ val state by viewModel.state.collectAsState()
+ val player = viewModel.player
+ val queue =
+ rememberQueue(
+ player,
+ state.musicServiceState.queueVersion,
+ state.musicServiceState.queueSize,
+ )
+ val current = queue.getOrNull(state.musicServiceState.currentIndex)
+ val viz by viewModel.viz.collectAsState()
+
+ val controllerViewState = viewModel.controllerViewState
+ val preferences =
+ viewModel.userPreferencesService.flow
+ .collectAsState(
+ UserPreferences(
+ AppPreferences.getDefaultInstance(),
+ ),
+ ).value.appPreferences
+ val musicPrefs = preferences.musicPreferences
+
+ val keyHandler =
+ remember(preferences) {
+ PlaybackKeyHandler(
+ player = player,
+ controlsEnabled = true,
+ skipWithLeftRight = false,
+ seekForward = 30.seconds,
+ seekBack = 10.seconds,
+// seekForward = preferences.playbackPreferences.skipForwardMs.milliseconds,
+// seekBack = preferences.playbackPreferences.skipBackMs.milliseconds,
+ controllerViewState = controllerViewState,
+ updateSkipIndicator = {},
+ skipBackOnResume = null,
+// skipBackOnResume = preferences.playbackPreferences.skipBackOnResume,
+ onInteraction = viewModel::reportInteraction,
+ oneClickPause = preferences.playbackPreferences.oneClickPause,
+ onStop = {
+ viewModel.stop()
+ },
+ onPlaybackDialogTypeClick = { },
+ getDurationMs = { player.duration },
+ )
+ }
+
+ val actions =
+ remember {
+ MusicQueueDialogActions(
+ onNavigate = { viewModel.navigationManager.navigateTo(it) },
+ onClickPlay = { index, _ -> viewModel.play(index) },
+ onClickPlayNext = { index, _ -> viewModel.playNext(index) },
+ onClickRemoveFromQueue = { index, _ -> viewModel.removeFromQueue(index) },
+ )
+ }
+
+ var showViewOptionsDialog by remember { mutableStateOf(false) }
+ var itemMoreDialog by remember { mutableStateOf(null) }
+ var lyricsHaveFocus by remember { mutableStateOf(false) }
+
+ val focusRequester = remember { FocusRequester() }
+ LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
+ val lyricsFocusRequester = remember { FocusRequester() }
+ val hasLyrics = musicPrefs.showLyrics && state.hasLyrics
+
+ LaunchedEffect(lyricsHaveFocus) {
+ if (lyricsHaveFocus) {
+ controllerViewState.hideControls()
+ }
+ }
+ BackHandler(lyricsHaveFocus) {
+ focusRequester.tryRequestFocus()
+ }
+
+ var showRationaleDialog by remember { mutableStateOf(false) }
+ val launcher =
+ rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission(),
+ ) { isGranted ->
+ viewModel.startVisualizer(isGranted, true)
+ }
+
+ Box(modifier) {
+ Backdrop(
+ backdrop = state.backdropResult,
+ drawerIsOpen = false,
+ backdropStyle = BackdropStyle.BACKDROP_DYNAMIC_COLOR,
+ modifier = Modifier.fillMaxSize(),
+ enableTopScrim = false,
+ useExistingImageAsPlaceholder = true,
+ crossfadeDuration = 1.5.seconds,
+ )
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .onPreviewKeyEvent { key ->
+ if (!controllerViewState.controlsVisible &&
+ key.type == KeyEventType.KeyUp &&
+ isUp(key) &&
+ hasLyrics
+ ) {
+ lyricsFocusRequester.tryRequestFocus()
+ true
+ } else {
+ keyHandler.onKeyEvent(key)
+ }
+ }.focusRequester(focusRequester)
+ .focusable(),
+ )
+ Row(
+ horizontalArrangement = Arrangement.Start,
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ Box(
+ modifier = Modifier.wrapContentWidth(),
+ ) {
+ val enter =
+ remember {
+ expandHorizontally(expandFrom = Alignment.Start) + fadeIn()
+ }
+ val exit =
+ remember {
+ shrinkHorizontally(shrinkTowards = Alignment.Start) + fadeOut()
+ }
+ androidx.compose.animation.AnimatedVisibility(
+ visible = musicPrefs.showAlbumArt,
+ enter = enter,
+ exit = exit,
+ modifier =
+ Modifier
+ .padding(32.dp),
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.fillMaxWidth(.5f),
+ ) {
+ AsyncImage(
+ contentDescription = null,
+ model = current?.imageUrl,
+ modifier =
+ Modifier
+ .size(240.dp)
+ .clip(RoundedCornerShape(16.dp)),
+ )
+ current?.title?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.titleLarge,
+ )
+ }
+ current?.albumTitle?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.titleMedium,
+ )
+ }
+ current?.artistNames?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.titleMedium,
+ )
+ }
+ }
+ }
+ androidx.compose.animation.AnimatedVisibility(
+ visible = musicPrefs.showVisualizer,
+ enter = enter,
+ exit = exit,
+ modifier =
+ Modifier
+ .padding(horizontal = 32.dp)
+ .align(Alignment.CenterStart),
+ ) {
+ val visualizerWidth by animateFloatAsState(if (musicPrefs.showLyrics && state.hasLyrics) .5f else 1f)
+ BarVisualizer(
+ data = viz,
+ modifier =
+ Modifier
+ .fillMaxHeight(.75f)
+ .fillMaxWidth(visualizerWidth)
+ .align(Alignment.CenterStart),
+ )
+ }
+ }
+
+ AnimatedVisibility(
+ visible = musicPrefs.showLyrics && state.hasLyrics,
+ enter = expandHorizontally(expandFrom = Alignment.End),
+ exit = shrinkHorizontally(shrinkTowards = Alignment.End),
+ modifier =
+ Modifier
+ .focusRequester(lyricsFocusRequester),
+ ) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier =
+ Modifier
+ .padding(horizontal = 32.dp, vertical = 100.dp)
+ .fillMaxHeight(),
+ ) {
+ LyricsContent(
+ lyrics = state.lyrics,
+ currentLyricPosition = state.currentLyricIndex,
+ lyricsHaveFocus = lyricsHaveFocus,
+ onFocusLyrics = { lyricsHaveFocus = it },
+ onClick = {
+ it.start
+ ?.ticks
+ ?.inWholeMilliseconds
+ ?.let { player.seekTo(it) }
+ },
+ modifier =
+ Modifier
+ .fillMaxSize(),
+// .width(360.dp),
+ )
+ }
+ }
+ }
+ val showContextForItem =
+ remember {
+ { fromLongClick: Boolean, index: Int, song: AudioItem ->
+ itemMoreDialog =
+ DialogParams(
+ title = song.title ?: "",
+ fromLongClick = fromLongClick,
+ items =
+ buildMoreDialogForMusicQueue(
+ context = context,
+ actions = actions,
+ item = song,
+ index = index,
+ canRemove = true,
+ ),
+ )
+ }
+ }
+
+ BackHandler(controllerViewState.controlsVisible) {
+ controllerViewState.hideControls()
+ }
+ AnimatedVisibility(
+ visible = controllerViewState.controlsVisible,
+ enter = slideInVertically { it },
+ exit = slideOutVertically { it },
+ modifier =
+ Modifier
+ .align(Alignment.BottomCenter),
+ ) {
+ NowPlayingOverlay(
+ state = state,
+ player = player,
+ current = current,
+ queue = queue,
+ controllerViewState = controllerViewState,
+ onMoveQueue = { index, direction -> viewModel.moveQueue(index, direction) },
+ onClickMore = { showViewOptionsDialog = true },
+ onClickSong = { index, _ -> viewModel.play(index) },
+ onClickMoreItem = { index, song -> showContextForItem.invoke(false, index, song) },
+ onLongClickSong = { index, song -> showContextForItem.invoke(true, index, song) },
+ onClickStop = { viewModel.stop() },
+ lyricsFocusRequester = lyricsFocusRequester,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .align(Alignment.BottomCenter)
+ .drawBehind {
+ drawRect(
+ brush =
+ Brush.verticalGradient(
+ colors = listOf(Color.Transparent, Color.Black),
+ startY = 0f,
+ endY = size.height,
+ ),
+ )
+ },
+ )
+ }
+ if (state.musicServiceState.loadingState is LoadingState.Loading) {
+ LoadingPage(focusEnabled = false)
+ }
+ }
+ itemMoreDialog?.let { params ->
+ DialogPopup(
+ showDialog = true,
+ title = params.title,
+ dialogItems = params.items,
+ onDismissRequest = { itemMoreDialog = null },
+ dismissOnClick = true,
+ waitToLoad = params.fromLongClick,
+ )
+ }
+ if (showViewOptionsDialog) {
+ MusicViewOptionsDialog(
+ appPreferences = preferences,
+ onDismissRequest = { showViewOptionsDialog = false },
+ onViewOptionsChange = { viewModel.updatePreferences(it) },
+ onEnableVisualizer = {
+ val showRationale =
+ context
+ .findActivity()
+ ?.shouldShowRequestPermissionRationale(Manifest.permission.RECORD_AUDIO) == true
+ when {
+ !state.visualizerPermissions && showRationale -> {
+ showRationaleDialog = true
+ }
+
+ !state.visualizerPermissions -> {
+ launcher.launch(Manifest.permission.RECORD_AUDIO)
+ }
+
+ else -> {
+ viewModel.startVisualizer(true, true)
+ }
+ }
+ },
+ )
+ }
+ if (showRationaleDialog) {
+ RecordAudioRationaleDialog(
+ onDismissRequest = { showRationaleDialog = false },
+ onClick = {
+ showRationaleDialog = false
+ launcher.launch(Manifest.permission.RECORD_AUDIO)
+ },
+ )
+ }
+}
+
+@Composable
+fun NowPlayingBottomDialog(
+ showDebugInfo: Boolean,
+ lyricsActive: Boolean,
+ songHasLyrics: Boolean,
+ onDismissRequest: () -> Unit,
+ onClickShowDebug: () -> Unit,
+ onClickLyrics: () -> Unit,
+) {
+ val choices =
+ mapOf(
+ BottomDialogItem(
+ data = 0,
+ headline = stringResource(if (showDebugInfo) R.string.hide_debug_info else R.string.show_debug_info),
+ supporting = null,
+ ) to onClickShowDebug,
+ BottomDialogItem(
+ data = 0,
+ headline = stringResource(if (lyricsActive) R.string.hide_lyrics else R.string.show_lyrics),
+ supporting = if (songHasLyrics) stringResource(R.string.song_has_lyrics) else null,
+ ) to onClickLyrics,
+ )
+
+ BottomDialog(
+ choices = choices.keys.toList(),
+ onDismissRequest = {
+ onDismissRequest.invoke()
+ },
+ onSelectChoice = { _, choice ->
+ choices[choice]?.invoke()
+ onDismissRequest.invoke()
+ },
+ gravity = Gravity.START,
+ )
+}
+
+@Composable
+private fun RecordAudioRationaleDialog(
+ onDismissRequest: () -> Unit,
+ onClick: () -> Unit,
+) {
+ BasicDialog(
+ onDismissRequest = onDismissRequest,
+ ) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier.padding(16.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.visualizer_rationale),
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ Button(
+ onClick = onClick,
+ modifier = Modifier.align(Alignment.CenterHorizontally),
+ ) {
+ Text(
+ text = stringResource(R.string.continue_string),
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingState.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingState.kt
new file mode 100644
index 00000000..f6fe40e4
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingState.kt
@@ -0,0 +1,17 @@
+package com.github.damontecres.wholphin.ui.detail.music
+
+import androidx.compose.runtime.Stable
+import com.github.damontecres.wholphin.services.BackdropResult
+import com.github.damontecres.wholphin.services.MusicServiceState
+import org.jellyfin.sdk.model.api.LyricDto
+
+@Stable
+data class NowPlayingState(
+ val musicServiceState: MusicServiceState,
+ val lyrics: LyricDto? = null,
+ val currentLyricIndex: Int? = null,
+ val visualizerPermissions: Boolean = false,
+ val backdropResult: BackdropResult = BackdropResult.NONE,
+) {
+ val hasLyrics: Boolean get() = lyrics != null && lyrics.lyrics.isNotEmpty()
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt
new file mode 100644
index 00000000..f1cc9225
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt
@@ -0,0 +1,406 @@
+package com.github.damontecres.wholphin.ui.detail.music
+
+import android.Manifest
+import android.content.Context
+import android.content.pm.PackageManager
+import android.media.audiofx.Visualizer
+import androidx.core.content.ContextCompat
+import androidx.datastore.core.DataStore
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.media3.common.MediaItem
+import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
+import com.github.damontecres.wholphin.data.model.AudioItem
+import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.preferences.AppPreference
+import com.github.damontecres.wholphin.preferences.AppPreferences
+import com.github.damontecres.wholphin.preferences.updateMusicPreferences
+import com.github.damontecres.wholphin.services.BackdropResult
+import com.github.damontecres.wholphin.services.BackdropService
+import com.github.damontecres.wholphin.services.ImageUrlService
+import com.github.damontecres.wholphin.services.MusicService
+import com.github.damontecres.wholphin.services.NavigationManager
+import com.github.damontecres.wholphin.services.NowPlayingStatus
+import com.github.damontecres.wholphin.services.UserPreferencesService
+import com.github.damontecres.wholphin.ui.launchDefault
+import com.github.damontecres.wholphin.ui.main.settings.MoveDirection
+import com.github.damontecres.wholphin.ui.onMain
+import com.github.damontecres.wholphin.ui.playback.ControllerViewState
+import com.mayakapps.kache.InMemoryKache
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import org.jellyfin.sdk.api.client.ApiClient
+import org.jellyfin.sdk.api.client.extensions.itemsApi
+import org.jellyfin.sdk.api.client.extensions.lyricsApi
+import org.jellyfin.sdk.api.client.extensions.userLibraryApi
+import org.jellyfin.sdk.model.api.BaseItemKind
+import org.jellyfin.sdk.model.api.ImageType
+import org.jellyfin.sdk.model.api.ItemSortBy
+import org.jellyfin.sdk.model.api.LyricDto
+import org.jellyfin.sdk.model.extensions.ticks
+import timber.log.Timber
+import java.util.UUID
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
+
+@UnstableApi
+@HiltViewModel(assistedFactory = NowPlayingViewModel.Factory::class)
+class NowPlayingViewModel
+ @AssistedInject
+ constructor(
+ @param:ApplicationContext private val context: Context,
+ private val api: ApiClient,
+ private val musicService: MusicService,
+ private val backdropService: BackdropService,
+ private val imageUrlService: ImageUrlService,
+ private val preferencesDataStore: DataStore,
+ val navigationManager: NavigationManager,
+ val userPreferencesService: UserPreferencesService,
+ ) : ViewModel(),
+ Visualizer.OnDataCaptureListener,
+ Player.Listener {
+ @AssistedFactory
+ interface Factory {
+ fun create(): NowPlayingViewModel
+ }
+
+ private val visualizerMutex = Mutex()
+ private var visualizer: Visualizer? = null
+
+ val controllerViewState =
+ ControllerViewState(
+ AppPreference.ControllerTimeout.defaultValue,
+ true,
+ )
+
+ val state = MutableStateFlow(NowPlayingState(musicService.state.value))
+ val player get() = musicService.player
+
+ val viz = MutableStateFlow(IntArray(0))
+
+ private val lyricCache =
+ InMemoryKache(20) {
+ creationScope = CoroutineScope(Dispatchers.IO)
+ }
+
+ init {
+ player.addListener(this)
+ addCloseable {
+ player.removeListener(this)
+ visualizer?.release()
+ }
+ val visualizerPermissions =
+ ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.RECORD_AUDIO,
+ ) == PackageManager.PERMISSION_GRANTED
+ startVisualizer(visualizerPermissions, false)
+ viewModelScope.launchDefault {
+ musicService.state.collectLatest { musicServiceState ->
+ if (musicServiceState.status != NowPlayingStatus.IDLE) {
+ visualizer?.enabled = musicServiceState.status == NowPlayingStatus.PLAYING
+ }
+
+ state.update { it.copy(musicServiceState = musicServiceState) }
+ }
+ }
+ viewModelScope.launchDefault {
+ viewModelScope
+ .launchDefault {
+ controllerViewState.observe()
+ }.join()
+ controllerViewState.pulseControls()
+ }
+ viewModelScope.launchDefault {
+ backdropService.clearBackdrop()
+ updateBackdrop(getCurrent())
+ }
+ playbackLoop()
+ }
+
+ fun reportInteraction() {
+ controllerViewState.pulseControls()
+ }
+
+ private suspend fun getCurrent(): AudioItem? {
+ val mediaItem =
+ onMain {
+ player.currentMediaItemIndex
+ .takeIf { it in 0..
+// Timber.v("Got current %s", audio.id)
+ if (audio.hasLyrics) {
+ val lyrics =
+ lyricCache.getOrPut(audio.id) {
+ // TODO remote lyrics?
+ api.lyricsApi.getLyrics(audio.id).content
+ }
+ val lyricIndex =
+ if (lyrics != null) {
+ val offset = lyrics.metadata.offset?.ticks ?: Duration.ZERO
+ val lyricPosition = offset + position
+ lyrics.lyrics
+ .indexOfLast {
+ it.start?.ticks?.let { lyricPosition >= it } == true
+ }.takeIf { it >= 0 }
+ } else {
+ null
+ }
+// Timber.v("lyricIndex=$lyricIndex")
+ state.update {
+ it.copy(
+ lyrics = lyrics,
+ currentLyricIndex = lyricIndex,
+ )
+ }
+ }
+ }
+
+ delay(150)
+ }
+ }
+ }
+
+ override fun onMediaItemTransition(
+ mediaItem: MediaItem?,
+ reason: Int,
+ ) {
+ val audio = mediaItem?.localConfiguration?.tag as? AudioItem
+ Timber.v("onMediaItemTransition to %s", audio?.id)
+ updateBackdrop(audio)
+ viewModelScope.launchDefault {
+ state.update {
+ it.copy(
+ lyrics = null,
+ currentLyricIndex = null,
+ )
+ }
+ audio?.let { audio ->
+ if (audio.hasLyrics) {
+ val lyrics =
+ lyricCache.getOrPut(audio.id) {
+ // TODO remote lyrics?
+ api.lyricsApi.getLyrics(audio.id).content
+ }
+ Timber.d("Got lyrics for %s: %s", audio.id, lyrics != null)
+ state.update {
+ it.copy(
+ lyrics = lyrics,
+ )
+ }
+ }
+ }
+ }
+ }
+
+ private var backDropJob: Job? = null
+
+ private fun updateBackdrop(audio: AudioItem?) {
+ backDropJob?.cancel()
+ backDropJob =
+ viewModelScope.launchDefault {
+ val showBackdrop =
+ userPreferencesService
+ .getCurrent()
+ .appPreferences.musicPreferences.showBackdrop
+ if (showBackdrop) {
+ var backdropItem: BaseItem? = null
+ try {
+ if (audio?.artistId != null) {
+ api.userLibraryApi.getItem(audio.artistId).content.let {
+ if (it.backdropImageTags?.isNotEmpty() == true) {
+ backdropItem = BaseItem(it, false)
+ }
+ }
+ }
+ if (backdropItem == null && audio?.albumId != null) {
+ api.userLibraryApi.getItem(audio.albumId).content.let {
+ backdropItem =
+ getBackdropItemForAlbum(api, BaseItem(it, false))
+ }
+ }
+ if (backdropItem != null) {
+ doUpdateBackdrop(backdropItem)
+ } else {
+ doUpdateBackdropRandom()
+ }
+ } catch (ex: Exception) {
+ Timber.e(ex, "Error fetching backdrop")
+ doUpdateBackdropRandom()
+ }
+ delay(60.seconds)
+ doUpdateBackdropRandom()
+ }
+ }
+ }
+
+ private suspend fun doUpdateBackdropRandom() {
+ val randomArtist =
+ api.itemsApi
+ .getItems(
+ recursive = true,
+ imageTypes = listOf(ImageType.BACKDROP),
+ includeItemTypes = listOf(BaseItemKind.MUSIC_ARTIST),
+ sortBy = listOf(ItemSortBy.RANDOM),
+ limit = 1,
+ ).content.items
+ .firstOrNull()
+ if (randomArtist != null) {
+ doUpdateBackdrop(BaseItem(randomArtist))
+ } else {
+ clearBackdrop()
+ }
+ }
+
+ private suspend fun doUpdateBackdrop(item: BaseItem) {
+ val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
+ val (primaryColor, secondaryColor, tertiaryColor) =
+ backdropService.extractColorsFromBackdrop(
+ imageUrl,
+ )
+ val backdropResult =
+ BackdropResult(
+ itemId = item.id.toString(),
+ imageUrl = imageUrl,
+ primaryColor = primaryColor,
+ secondaryColor = secondaryColor,
+ tertiaryColor = tertiaryColor,
+ )
+ state.update { it.copy(backdropResult = backdropResult) }
+ }
+
+ private fun clearBackdrop() {
+ state.update { it.copy(backdropResult = BackdropResult.NONE) }
+ }
+
+ fun moveQueue(
+ index: Int,
+ direction: MoveDirection,
+ ) = viewModelScope.launchDefault { musicService.moveQueue(index, direction) }
+
+ fun play(index: Int) = viewModelScope.launchDefault { musicService.playIndex(index) }
+
+ fun playNext(index: Int) = viewModelScope.launchDefault { musicService.moveQueue(index, 1) }
+
+ fun removeFromQueue(index: Int) = viewModelScope.launchDefault { musicService.removeFromQueue(index) }
+
+ fun stop() {
+ viewModelScope.launchDefault {
+ musicService.stop()
+ navigationManager.goBack()
+ }
+ }
+
+ override fun onFftDataCapture(
+ visualizer: Visualizer,
+ fft: ByteArray,
+ samplingRate: Int,
+ ) {
+ }
+
+ override fun onWaveFormDataCapture(
+ visualizer: Visualizer,
+ waveform: ByteArray,
+ samplingRate: Int,
+ ) {
+ val resolution = 96
+ val captureSize =
+ Visualizer.getCaptureSizeRange()[1]
+ val groupSize = (captureSize / resolution.toFloat()).toInt()
+ val processed =
+ waveform
+ .toList()
+ .chunked(groupSize)
+ .map { it.average().toInt() + 128 }
+ .toIntArray()
+ viz.update { processed }
+ }
+
+ fun updatePreferences(prefs: AppPreferences) {
+ viewModelScope.launchDefault {
+ var backdropChanged = false
+ preferencesDataStore.updateData {
+ backdropChanged =
+ it.musicPreferences.showBackdrop != prefs.musicPreferences.showBackdrop
+ prefs
+ }
+ if (backdropChanged) {
+ if (prefs.musicPreferences.showBackdrop) {
+ updateBackdrop(getCurrent())
+ } else {
+ clearBackdrop()
+ }
+ }
+ }
+ }
+
+ private fun initVisualizer() {
+ viewModelScope.launchDefault {
+ visualizerMutex.withLock {
+ val prefs = preferencesDataStore.data.first()
+ if (visualizer == null &&
+ state.value.visualizerPermissions &&
+ prefs.musicPreferences.showVisualizer
+ ) {
+ Timber.v("Creating visualizer")
+ visualizer =
+ Visualizer(onMain { player.audioSessionId }).apply {
+ captureSize = Visualizer.getCaptureSizeRange()[1]
+ setDataCaptureListener(
+ this@NowPlayingViewModel,
+ Visualizer.getMaxCaptureRate() / 3,
+ true,
+ false,
+ )
+ enabled = true
+ }
+ }
+ }
+ }
+ }
+
+ fun startVisualizer(
+ permissionGranted: Boolean,
+ updatePreferences: Boolean,
+ ) {
+ Timber.v("startVisualizer: permissionGranted=%s", permissionGranted)
+ state.update {
+ it.copy(
+ visualizerPermissions = permissionGranted,
+ )
+ }
+ viewModelScope.launchDefault {
+ if (updatePreferences || !permissionGranted) {
+ preferencesDataStore.updateData {
+ it.updateMusicPreferences { showVisualizer = permissionGranted }
+ }
+ }
+ initVisualizer()
+ }
+ }
+ }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt
new file mode 100644
index 00000000..82846282
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt
@@ -0,0 +1,237 @@
+package com.github.damontecres.wholphin.ui.detail.music
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsFocusedAsState
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.Icon
+import androidx.tv.material3.ListItem
+import androidx.tv.material3.ListItemDefaults
+import androidx.tv.material3.LocalContentColor
+import androidx.tv.material3.Text
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.ui.PreviewTvSpec
+import com.github.damontecres.wholphin.ui.components.Button
+import com.github.damontecres.wholphin.ui.enableMarquee
+import com.github.damontecres.wholphin.ui.letNotEmpty
+import com.github.damontecres.wholphin.ui.roundSeconds
+import com.github.damontecres.wholphin.ui.theme.WholphinTheme
+import org.jellyfin.sdk.model.extensions.ticks
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Duration.Companion.seconds
+
+@Composable
+fun SongListItem(
+ song: BaseItem?,
+ onClick: () -> Unit,
+ onLongClick: () -> Unit,
+ onClickMore: () -> Unit,
+ modifier: Modifier = Modifier,
+ showArtist: Boolean = false,
+ isPlaying: Boolean = false,
+ isQueued: Boolean = false,
+) = SongListItem(
+ title = song?.title,
+ artist = if (showArtist) song?.data?.albumArtist else null,
+ indexNumber = song?.data?.indexNumber,
+ runtime =
+ remember(song) {
+ song
+ ?.data
+ ?.runTimeTicks
+ ?.ticks
+ ?.roundSeconds
+ },
+ onClick = onClick,
+ onLongClick = onLongClick,
+ modifier = modifier,
+ showArtist = showArtist,
+ isPlaying = isPlaying,
+ isQueued = isQueued,
+ showMoreButton = true,
+ onClickMore = onClickMore,
+)
+
+@Composable
+fun SongListItem(
+ title: String?,
+ artist: String?,
+ indexNumber: Int?,
+ runtime: Duration?,
+ onClick: () -> Unit,
+ onLongClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ showArtist: Boolean = false,
+ isPlaying: Boolean = false,
+ isQueued: Boolean = false,
+ showMoreButton: Boolean = false,
+ onClickMore: () -> Unit = {},
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = modifier,
+ ) {
+ val focused by interactionSource.collectIsFocusedAsState()
+ val leadingContent: @Composable (BoxScope.() -> Unit) = {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = indexNumber?.toString() ?: "",
+ )
+ MusicQueueMarker(
+ isPlaying = isPlaying,
+ isQueued = isQueued,
+ )
+ }
+ }
+ val headlineContent = @Composable {
+ Text(
+ text = title ?: "",
+ maxLines = 1,
+ modifier = Modifier.enableMarquee(focused),
+ )
+ }
+ val trailingContent = @Composable {
+ Text(
+ text = runtime.toString(),
+ )
+ }
+
+ if (showArtist) {
+ // TODO use dense?
+ ListItem(
+ selected = isPlaying,
+ onClick = onClick,
+ onLongClick = onLongClick,
+ interactionSource = interactionSource,
+ leadingContent = leadingContent,
+ headlineContent = headlineContent,
+ supportingContent = {
+ Text(
+ text = artist ?: "",
+ )
+ },
+ trailingContent = trailingContent,
+ scale = ListItemDefaults.scale(1f, 1f, .95f),
+ modifier = Modifier.weight(1f),
+ )
+ } else {
+ ListItem(
+ selected = isPlaying,
+ onClick = onClick,
+ onLongClick = onLongClick,
+ interactionSource = interactionSource,
+ leadingContent = leadingContent,
+ headlineContent = headlineContent,
+ supportingContent = null,
+ trailingContent = trailingContent,
+ scale = ListItemDefaults.scale(1f, 1f, .95f),
+ modifier = Modifier.weight(1f),
+ )
+ }
+ if (showMoreButton) {
+ Button(
+ onClick = onClickMore,
+ enabled = true,
+ modifier = Modifier.size(32.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Default.MoreVert,
+ contentDescription = stringResource(R.string.more),
+ )
+ }
+ }
+ }
+}
+
+val BaseItem.artistsString: String? get() = data.artists?.letNotEmpty { it.joinToString(", ") }
+
+/**
+ * Add an indicator for if the item is currently playing or queued
+ */
+@Composable
+fun MusicQueueMarker(
+ isPlaying: Boolean,
+ isQueued: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ if (isPlaying) {
+ Icon(
+ imageVector = Icons.Default.PlayArrow,
+ contentDescription = null,
+ modifier = modifier,
+ )
+ } else if (isQueued) {
+ Box(
+ modifier =
+ modifier
+ .padding(horizontal = 8.dp)
+ .clip(CircleShape)
+ .background(LocalContentColor.current)
+ .size(8.dp),
+ )
+ }
+}
+
+@PreviewTvSpec
+@Composable
+fun SongListItemPreview() {
+ WholphinTheme {
+ Column {
+ SongListItem(
+ title = "Song title",
+ artist = "Artists",
+ indexNumber = 1,
+ runtime = 2.minutes + 30.seconds,
+ onClick = {},
+ onLongClick = { },
+ modifier = Modifier,
+ showArtist = false,
+ )
+ SongListItem(
+ title = "Song title",
+ artist = "Artists",
+ indexNumber = 1,
+ runtime = 2.minutes + 30.seconds,
+ onClick = {},
+ onLongClick = { },
+ modifier = Modifier,
+ showArtist = false,
+ isQueued = true,
+ showMoreButton = true,
+ )
+ SongListItem(
+ title = "Song title",
+ artist = "Artists",
+ indexNumber = 2,
+ runtime = 2.minutes + 30.seconds,
+ onClick = {},
+ onLongClick = { },
+ modifier = Modifier,
+ showArtist = true,
+ isPlaying = true,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/Utils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/Utils.kt
new file mode 100644
index 00000000..5fae787c
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/Utils.kt
@@ -0,0 +1,269 @@
+package com.github.damontecres.wholphin.ui.detail.music
+
+import android.content.Context
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.ArrowForward
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.ui.graphics.Color
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.data.model.AudioItem
+import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.ui.DefaultItemFields
+import com.github.damontecres.wholphin.ui.components.DialogItem
+import com.github.damontecres.wholphin.ui.nav.Destination
+import com.github.damontecres.wholphin.util.ApiRequestPager
+import com.github.damontecres.wholphin.util.GetItemsRequestHandler
+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.request.GetItemsRequest
+import java.util.UUID
+
+data class MusicMoreDialogActions(
+ val onNavigate: (Destination) -> Unit,
+ val onClickPlay: (Int, BaseItem) -> Unit,
+ val onClickPlayNext: (Int, BaseItem) -> Unit,
+ val onClickAddToQueue: (Int, BaseItem) -> Unit,
+ val onClickFavorite: (UUID, Boolean) -> Unit,
+ val onClickAddPlaylist: (UUID) -> Unit,
+ val onClickGoToAlbum: (UUID) -> Unit = {
+ onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ALBUM))
+ },
+ val onClickGoToArtist: (UUID) -> Unit = {
+ onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ARTIST))
+ },
+ val onClickRemoveFromQueue: (Int) -> Unit,
+ val onClickDelete: (BaseItem) -> Unit,
+)
+
+fun buildMoreDialogForMusic(
+ context: Context,
+ actions: MusicMoreDialogActions,
+ item: BaseItem,
+ index: Int,
+ canRemove: Boolean,
+ canDelete: Boolean,
+): List =
+ buildList {
+ add(
+ DialogItem(
+ context.getString(R.string.play),
+ Icons.Default.PlayArrow,
+ iconColor = Color.Green.copy(alpha = .8f),
+ ) {
+ actions.onClickPlay(index, item)
+ },
+ )
+ add(
+ DialogItem(
+ context.getString(R.string.play_next),
+ Icons.Default.PlayArrow,
+ iconColor = Color.Green.copy(alpha = .8f),
+ ) {
+ actions.onClickPlayNext(index, item)
+ },
+ )
+ if (canRemove) {
+ add(
+ DialogItem(
+ context.getString(R.string.remove_from_queue),
+ Icons.Default.Delete,
+ ) {
+ actions.onClickRemoveFromQueue(index)
+ },
+ )
+ }
+ add(
+ DialogItem(
+ context.getString(R.string.add_to_queue),
+ Icons.Default.Add,
+ ) {
+ actions.onClickAddToQueue(index, item)
+ },
+ )
+ add(
+ DialogItem(
+ text = R.string.add_to_playlist,
+ iconStringRes = R.string.fa_list_ul,
+ ) {
+ actions.onClickAddPlaylist.invoke(item.id)
+ },
+ )
+ if (canDelete) {
+ add(
+ DialogItem(
+ context.getString(R.string.delete),
+ Icons.Default.Delete,
+ iconColor = Color.Red.copy(alpha = .8f),
+ ) {
+ actions.onClickDelete.invoke(item)
+ },
+ )
+ }
+ add(
+ DialogItem(
+ text = if (item.favorite) R.string.remove_favorite else R.string.add_favorite,
+ iconStringRes = R.string.fa_heart,
+ iconColor = if (item.favorite) Color.Red else Color.Unspecified,
+ ) {
+ actions.onClickFavorite.invoke(item.id, !item.favorite)
+ },
+ )
+ if (item.type == BaseItemKind.AUDIO && item.data.albumId != null) {
+ add(
+ DialogItem(
+ context.getString(R.string.go_to_album),
+ R.string.fa_compact_disc,
+ ) {
+ actions.onClickGoToAlbum.invoke(item.data.albumId!!)
+ },
+ )
+ }
+ if ((item.type == BaseItemKind.AUDIO || item.type == BaseItemKind.MUSIC_ALBUM) && item.data.artistItems?.isNotEmpty() == true) {
+ add(
+ DialogItem(
+ context.getString(R.string.go_to_artist),
+ R.string.fa_user,
+ ) {
+ actions.onClickGoToArtist.invoke(
+ item.data.artistItems!!
+ .first()
+ .id,
+ )
+ },
+ )
+ }
+ }
+
+data class MusicQueueDialogActions(
+ val onNavigate: (Destination) -> Unit,
+ val onClickPlay: (Int, AudioItem) -> Unit,
+ val onClickPlayNext: (Int, AudioItem) -> Unit,
+ val onClickGoToAlbum: (UUID) -> Unit = {
+ onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ALBUM))
+ },
+ val onClickGoToArtist: (UUID) -> Unit = {
+ onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ARTIST))
+ },
+ val onClickRemoveFromQueue: (Int, AudioItem) -> Unit,
+)
+
+fun buildMoreDialogForMusicQueue(
+ context: Context,
+ actions: MusicQueueDialogActions,
+ item: AudioItem,
+ index: Int,
+ canRemove: Boolean,
+): List =
+ buildList {
+ add(
+ DialogItem(
+ context.getString(R.string.play),
+ Icons.Default.PlayArrow,
+ iconColor = Color.Green.copy(alpha = .8f),
+ ) {
+ actions.onClickPlay(index, item)
+ },
+ )
+ add(
+ DialogItem(
+ context.getString(R.string.play_next),
+ Icons.Default.PlayArrow,
+ iconColor = Color.Green.copy(alpha = .8f),
+ ) {
+ actions.onClickPlayNext(index, item)
+ },
+ )
+ if (canRemove) {
+ add(
+ DialogItem(
+ context.getString(R.string.remove_from_queue),
+ Icons.Default.Delete,
+ ) {
+ actions.onClickRemoveFromQueue(index, item)
+ },
+ )
+ }
+ if (item.albumId != null) {
+ add(
+ DialogItem(
+ context.getString(R.string.go_to_album),
+ Icons.Default.ArrowForward,
+ ) {
+ actions.onClickGoToAlbum.invoke(item.albumId)
+ },
+ )
+ }
+ if (item.artistId != null) {
+ add(
+ DialogItem(
+ context.getString(R.string.go_to_artist),
+ Icons.Default.ArrowForward,
+ ) {
+ actions.onClickGoToArtist.invoke(item.artistId)
+ },
+ )
+ }
+ }
+
+suspend fun ViewModel.getPagerForAlbum(
+ api: ApiClient,
+ albumId: UUID,
+): ApiRequestPager {
+ val request =
+ GetItemsRequest(
+ parentId = albumId,
+ includeItemTypes = listOf(BaseItemKind.AUDIO),
+ fields = DefaultItemFields,
+ sortBy =
+ listOf(
+ ItemSortBy.PARENT_INDEX_NUMBER,
+ ItemSortBy.INDEX_NUMBER,
+ ItemSortBy.SORT_NAME,
+ ),
+ )
+ return ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init()
+}
+
+suspend fun ViewModel.getPagerForArtist(
+ api: ApiClient,
+ artistId: UUID,
+): ApiRequestPager {
+ val request =
+ GetItemsRequest(
+ artistIds = listOf(artistId),
+ recursive = true,
+ includeItemTypes = listOf(BaseItemKind.AUDIO),
+ fields = DefaultItemFields,
+ // TODO better sort
+ sortBy =
+ listOf(
+ ItemSortBy.PARENT_INDEX_NUMBER,
+ ItemSortBy.INDEX_NUMBER,
+ ItemSortBy.SORT_NAME,
+ ),
+ )
+ return ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init()
+}
+
+suspend fun ViewModel.getPagerForPlaylist(
+ api: ApiClient,
+ playlistId: UUID,
+): ApiRequestPager {
+ val request =
+ GetItemsRequest(
+ parentId = playlistId,
+ recursive = true,
+ includeItemTypes = listOf(BaseItemKind.AUDIO),
+ fields = DefaultItemFields,
+ sortBy =
+ listOf(
+ ItemSortBy.DEFAULT,
+ ),
+ )
+ return ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init()
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt
index aab6e056..b63f1cbc 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt
@@ -13,6 +13,7 @@ import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.EpisodeName
+import com.github.damontecres.wholphin.ui.components.HeaderUtils
import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.QuickDetails
import com.github.damontecres.wholphin.ui.components.VideoStreamDetails
@@ -32,17 +33,21 @@ fun FocusedEpisodeHeader(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
- EpisodeName(dto, modifier = Modifier.padding(start = 8.dp))
+ EpisodeName(dto, modifier = Modifier.padding(start = HeaderUtils.startPadding))
ep?.ui?.quickDetails?.let {
- QuickDetails(it, ep.timeRemainingOrRuntime, Modifier.padding(start = 8.dp))
+ QuickDetails(
+ it,
+ ep.timeRemainingOrRuntime,
+ Modifier.padding(start = HeaderUtils.startPadding),
+ )
}
if (dto != null) {
VideoStreamDetails(
chosenStreams = chosenStreams,
numberOfVersions = dto.mediaSourceCount ?: 0,
- modifier = Modifier.padding(start = 8.dp),
+ modifier = Modifier.padding(start = HeaderUtils.startPadding),
)
}
OverviewText(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt
index 4f8e4366..3c6b7d5c 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt
@@ -37,20 +37,18 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.tv.material3.MaterialTheme
-import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ExtrasItem
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.Trailer
+import com.github.damontecres.wholphin.data.model.studioNames
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.ui.Cards
@@ -69,10 +67,12 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
import com.github.damontecres.wholphin.ui.components.GenreText
+import com.github.damontecres.wholphin.ui.components.HeaderUtils
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.Optional
import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.QuickDetails
+import com.github.damontecres.wholphin.ui.components.TitleOrLogo
import com.github.damontecres.wholphin.ui.components.TrailerButton
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
@@ -89,6 +89,7 @@ import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.util.DataLoadingState
+import com.github.damontecres.wholphin.util.DiscoverRequestType
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch
@@ -132,6 +133,14 @@ fun SeriesDetails(
var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) }
var showDeleteDialog by remember { mutableStateOf(null) }
+ LifecycleResumeEffect(destination.itemId) {
+ viewModel.refresh()
+
+ onPauseOrDispose {
+ viewModel.release()
+ }
+ }
+
when (val state = loading) {
is LoadingState.Error -> {
ErrorMessage(state, modifier)
@@ -148,9 +157,7 @@ fun SeriesDetails(
LifecycleResumeEffect(destination.itemId) {
viewModel.onResumePage()
- onPauseOrDispose {
- viewModel.release()
- }
+ onPauseOrDispose {}
}
val played = item.data.userData?.played ?: false
@@ -203,13 +210,7 @@ fun SeriesDetails(
}
},
overviewOnClick = {
- overviewDialog =
- ItemDetailsDialogInfo(
- title = item.name ?: context.getString(R.string.unknown),
- overview = item.data.overview,
- genres = item.data.genres.orEmpty(),
- files = listOf(),
- )
+ overviewDialog = ItemDetailsDialogInfo(item)
},
playOnClick = { shuffle ->
if (shuffle) {
@@ -380,7 +381,6 @@ fun SeriesDetailsContent(
Column(
modifier =
Modifier
- .padding(vertical = 16.dp)
.fillMaxSize(),
) {
LazyColumn(
@@ -391,18 +391,20 @@ fun SeriesDetailsContent(
item {
SeriesDetailsHeader(
series = series,
+ showLogo = preferences.appPreferences.interfacePreferences.showLogos,
overviewOnClick = overviewOnClick,
+ bringIntoViewRequester = bringIntoViewRequester,
modifier =
Modifier
.fillMaxWidth()
.bringIntoViewRequester(bringIntoViewRequester)
- .padding(top = 32.dp, bottom = 16.dp),
+ .padding(top = HeaderUtils.topPadding, bottom = 16.dp),
)
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier =
Modifier
- .padding(start = 8.dp)
+ .padding(start = HeaderUtils.startPadding)
.focusRequester(focusRequesters[HEADER_ROW])
.focusRestorer(playFocusRequester)
.focusGroup()
@@ -649,6 +651,7 @@ fun SeriesDetailsContent(
DiscoverRowData(
stringResource(R.string.discover),
DataLoadingState.Success(discovered),
+ type = DiscoverRequestType.UNKNOWN,
),
onClickItem = { index: Int, item: DiscoverItem ->
position = DISCOVER_ROW
@@ -678,7 +681,9 @@ fun SeriesDetailsContent(
@Composable
fun SeriesDetailsHeader(
series: BaseItem,
+ showLogo: Boolean,
overviewOnClick: () -> Unit,
+ bringIntoViewRequester: BringIntoViewRequester,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
@@ -687,24 +692,33 @@ fun SeriesDetailsHeader(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier,
) {
- Text(
- text = series.name ?: stringResource(R.string.unknown),
- color = MaterialTheme.colorScheme.onBackground,
- style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
+ TitleOrLogo(
+ item = series,
+ showLogo = showLogo,
modifier =
Modifier
.fillMaxWidth(.75f)
- .padding(start = 8.dp),
+ .padding(start = HeaderUtils.startPadding),
)
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth(.60f),
) {
- QuickDetails(series.ui.quickDetails, null, Modifier.padding(start = 8.dp))
+ QuickDetails(
+ series.ui.quickDetails,
+ null,
+ Modifier.padding(start = HeaderUtils.startPadding),
+ )
+ dto.studios?.let {
+ val studios = remember { series.studioNames }
+ GenreText(
+ studios,
+ textStyle = MaterialTheme.typography.bodyLarge,
+ modifier = Modifier.padding(start = HeaderUtils.startPadding),
+ )
+ }
dto.genres?.letNotEmpty {
- GenreText(it, Modifier.padding(start = 8.dp, bottom = 8.dp))
+ GenreText(it, Modifier.padding(start = HeaderUtils.startPadding, bottom = 4.dp))
}
dto.overview?.let { overview ->
OverviewText(
@@ -712,6 +726,14 @@ fun SeriesDetailsHeader(
maxLines = 3,
onClick = overviewOnClick,
textBoxHeight = Dp.Unspecified,
+ modifier =
+ Modifier.onFocusChanged {
+ if (it.isFocused) {
+ scope.launch(ExceptionHandler()) {
+ bringIntoViewRequester.bringIntoView()
+ }
+ }
+ },
)
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt
index 84180f20..23633651 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt
@@ -225,6 +225,7 @@ fun SeriesOverview(
chooseVersionParams(
context,
ep.data.mediaSources!!,
+ chosenStreams?.source?.id?.toUUIDOrNull(),
) { idx ->
val source = ep.data.mediaSources!![idx]
viewModel.savePlayVersion(
@@ -264,13 +265,7 @@ fun SeriesOverview(
}
},
onShowOverview = {
- overviewDialog =
- ItemDetailsDialogInfo(
- title = ep.name ?: context.getString(R.string.unknown),
- overview = ep.data.overview,
- genres = ep.data.genres.orEmpty(),
- files = ep.data.mediaSources.orEmpty(),
- )
+ overviewDialog = ItemDetailsDialogInfo(ep)
},
onClearChosenStreams = {
viewModel.clearChosenStreams(ep, chosenStreams)
@@ -356,15 +351,7 @@ fun SeriesOverview(
},
overviewOnClick = {
episodeList?.getOrNull(position.episodeRowIndex)?.let {
- scope.launchDefault {
- overviewDialog =
- ItemDetailsDialogInfo(
- title = it.name ?: context.getString(R.string.unknown),
- overview = it.data.overview,
- genres = it.data.genres.orEmpty(),
- files = it.data.mediaSources.orEmpty(),
- )
- }
+ overviewDialog = ItemDetailsDialogInfo(it)
}
},
personOnClick = {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt
index 8acc32f1..34a9539a 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt
@@ -30,6 +30,7 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@@ -39,6 +40,7 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
@@ -52,9 +54,10 @@ import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.cards.BannerCard
import com.github.damontecres.wholphin.ui.cards.PersonRow
import com.github.damontecres.wholphin.ui.components.ErrorMessage
+import com.github.damontecres.wholphin.ui.components.HeaderUtils
import com.github.damontecres.wholphin.ui.components.LoadingPage
-import com.github.damontecres.wholphin.ui.components.SeriesName
import com.github.damontecres.wholphin.ui.components.TabRow
+import com.github.damontecres.wholphin.ui.components.TitleOrLogo
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.logTab
import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp
@@ -94,7 +97,7 @@ fun SeriesOverviewContent(
) {
val scope = rememberCoroutineScope()
val bringIntoViewRequester = remember { BringIntoViewRequester() }
- var selectedTabIndex by rememberSaveable(position) { mutableIntStateOf(position.seasonTabIndex) }
+ var selectedTabIndex by rememberSaveable(position.seasonTabIndex) { mutableIntStateOf(position.seasonTabIndex) }
LaunchedEffect(selectedTabIndex) {
logTab("series_overview", selectedTabIndex)
}
@@ -119,6 +122,8 @@ fun SeriesOverviewContent(
}
val focusRequesters = remember(seasons) { List(seasons.size) { FocusRequester() } }
+ val currentOnChangeSeason by rememberUpdatedState(onChangeSeason)
+
Box(
modifier =
modifier
@@ -143,19 +148,24 @@ fun SeriesOverviewContent(
.bringIntoViewRequester(bringIntoViewRequester),
) {
val paddingValues =
- if (preferences.appPreferences.interfacePreferences.showClock) {
- PaddingValues(start = 0.dp, end = 184.dp)
- } else {
- PaddingValues(start = 0.dp, end = 16.dp)
+ remember(preferences.appPreferences.interfacePreferences.showClock) {
+ if (preferences.appPreferences.interfacePreferences.showClock) {
+ PaddingValues(start = 0.dp, end = 184.dp)
+ } else {
+ PaddingValues(start = 0.dp, end = 16.dp)
+ }
}
TabRow(
selectedTabIndex = selectedTabIndex,
tabs = tabs,
- onClick = {
- selectedTabIndex = it
- onChangeSeason.invoke(it)
- requestFocusAfterSeason = true
- },
+ onClick =
+ remember {
+ {
+ selectedTabIndex = it
+ currentOnChangeSeason(it)
+ requestFocusAfterSeason = true
+ }
+ },
focusRequesters = focusRequesters,
modifier =
Modifier
@@ -164,7 +174,11 @@ fun SeriesOverviewContent(
.padding(bottom = 4.dp)
.fillMaxWidth(),
)
- SeriesName(series.name, Modifier.padding(start = 8.dp))
+ TitleOrLogo(
+ item = series,
+ showLogo = preferences.appPreferences.interfacePreferences.showLogos,
+ modifier = Modifier.padding(start = HeaderUtils.startPadding),
+ )
FocusedEpisodeHeader(
preferences = preferences,
ep = focusedEpisode,
@@ -247,14 +261,21 @@ fun SeriesOverviewContent(
).ifElse(
episodeIndex == epPosition,
Modifier.focusRequester(episodeRowFocusRequester),
- ).ifElse(
- episodeIndex != position.episodeRowIndex,
- Modifier
- .background(
- Color.Black,
- shape = RoundedCornerShape(8.dp),
- ).alpha(dimming),
- ).onFocusChanged {
+ ).background(
+ if (episodeIndex != position.episodeRowIndex) {
+ Color.Black
+ } else {
+ Color.Transparent
+ },
+ shape = RoundedCornerShape(8.dp),
+ ).graphicsLayer {
+ alpha =
+ if (episodeIndex != position.episodeRowIndex) {
+ dimming
+ } else {
+ 1f
+ }
+ }.onFocusChanged {
if (it.isFocused) {
scope.launch {
bringIntoViewRequester.bringIntoView()
@@ -306,17 +327,9 @@ fun SeriesOverviewContent(
}
}
- val castAndCrew =
+ val (guestStars, castAndCrew) =
remember(peopleInEpisode) {
- peopleInEpisode.filterNot {
- it.type == PersonKind.GUEST_STAR
- }
- }
- val guestStars =
- remember(peopleInEpisode) {
- peopleInEpisode.filter {
- it.type == PersonKind.GUEST_STAR
- }
+ peopleInEpisode.partition { it.type == PersonKind.GUEST_STAR }
}
AnimatedVisibility(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt
index 2d044670..de04149b 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt
@@ -271,9 +271,9 @@ class SeriesViewModel
}
fun onResumePage() {
- viewModelScope.launchIO {
- item.value?.let {
- backdropService.submit(it)
+ item.value?.let { item ->
+ viewModelScope.launchDefault { backdropService.submit(item) }
+ viewModelScope.launchIO {
val playThemeSongs =
userPreferencesService
.getCurrent()
@@ -283,6 +283,16 @@ class SeriesViewModel
}
}
+ fun refresh() {
+ item.value?.let { item ->
+ if (loading.value == LoadingState.Success) {
+ viewModelScope.launchIO {
+ (seasons.value as? ApiRequestPager<*>)?.refresh()
+ }
+ }
+ }
+ }
+
fun release() {
themeSongPlayer.stop()
}
@@ -292,6 +302,7 @@ class SeriesViewModel
seasonNum: Int?,
): Deferred> =
viewModelScope.async(Dispatchers.IO) {
+ Timber.v("getSeasons for %s", series.id)
val request =
GetItemsRequest(
parentId = series.id,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt
new file mode 100644
index 00000000..b3460265
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt
@@ -0,0 +1,198 @@
+package com.github.damontecres.wholphin.ui.discover
+
+import android.content.Context
+import androidx.compose.foundation.layout.Column
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.Dp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.github.damontecres.wholphin.data.model.DiscoverItem
+import com.github.damontecres.wholphin.services.NavigationManager
+import com.github.damontecres.wholphin.services.SeerrApi
+import com.github.damontecres.wholphin.services.SeerrService
+import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
+import com.github.damontecres.wholphin.ui.components.ErrorMessage
+import com.github.damontecres.wholphin.ui.components.GridTitle
+import com.github.damontecres.wholphin.ui.components.LoadingPage
+import com.github.damontecres.wholphin.ui.detail.CardGrid
+import com.github.damontecres.wholphin.ui.launchDefault
+import com.github.damontecres.wholphin.ui.nav.Destination
+import com.github.damontecres.wholphin.ui.tryRequestFocus
+import com.github.damontecres.wholphin.util.DataLoadingState
+import com.github.damontecres.wholphin.util.DiscoverMovieRequestHandler
+import com.github.damontecres.wholphin.util.DiscoverRequestPager
+import com.github.damontecres.wholphin.util.DiscoverRequestType
+import com.github.damontecres.wholphin.util.DiscoverTvRequestHandler
+import com.github.damontecres.wholphin.util.TrendingRequestHandler
+import com.github.damontecres.wholphin.util.UpcomingMovieRequestHandler
+import com.github.damontecres.wholphin.util.UpcomingTvRequestHandler
+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.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.update
+import timber.log.Timber
+
+@HiltViewModel(assistedFactory = DiscoverRequestViewModel.Factory::class)
+class DiscoverRequestViewModel
+ @AssistedInject
+ constructor(
+ @param:ApplicationContext private val context: Context,
+ private val seerrService: SeerrService,
+ val navigationManager: NavigationManager,
+ private val api: SeerrApi,
+ @Assisted val type: DiscoverRequestType,
+ @Assisted startIndex: Int,
+ ) : ViewModel() {
+ @AssistedFactory
+ interface Factory {
+ fun create(
+ type: DiscoverRequestType,
+ startIndex: Int,
+ ): DiscoverRequestViewModel
+ }
+
+ private val _state = MutableStateFlow(DiscoverRequestState())
+ val state: StateFlow = _state
+
+ init {
+ viewModelScope.launchDefault {
+ try {
+ val pager =
+ when (type) {
+ DiscoverRequestType.DISCOVER_TV -> {
+ DiscoverRequestPager(
+ api,
+ DiscoverTvRequestHandler,
+ seerrService::createDiscoverItem,
+ viewModelScope,
+ )
+ }
+
+ DiscoverRequestType.DISCOVER_MOVIES -> {
+ DiscoverRequestPager(
+ api,
+ DiscoverMovieRequestHandler,
+ seerrService::createDiscoverItem,
+ viewModelScope,
+ )
+ }
+
+ DiscoverRequestType.TRENDING -> {
+ DiscoverRequestPager(
+ api,
+ TrendingRequestHandler,
+ seerrService::createDiscoverItem,
+ viewModelScope,
+ )
+ }
+
+ DiscoverRequestType.UPCOMING_TV -> {
+ DiscoverRequestPager(
+ api,
+ UpcomingTvRequestHandler,
+ seerrService::createDiscoverItem,
+ viewModelScope,
+ )
+ }
+
+ DiscoverRequestType.UPCOMING_MOVIES -> {
+ DiscoverRequestPager(
+ api,
+ UpcomingMovieRequestHandler,
+ seerrService::createDiscoverItem,
+ viewModelScope,
+ )
+ }
+
+ DiscoverRequestType.UNKNOWN -> {
+ throw IllegalArgumentException("Cannot display grid for DiscoverRequestType.UNKNOWN")
+ }
+ }.init(startIndex)
+ _state.update {
+ it.copy(
+ loading = DataLoadingState.Success(pager),
+ )
+ }
+ } catch (ex: Exception) {
+ Timber.e(ex, "Error initializing %s", type)
+ _state.update {
+ it.copy(
+ loading = DataLoadingState.Error(ex),
+ )
+ }
+ }
+ }
+ }
+ }
+
+data class DiscoverRequestState(
+ val loading: DataLoadingState> = DataLoadingState.Pending,
+)
+
+@Composable
+fun DiscoverRequestGrid(
+ destination: Destination.DiscoverMoreResult,
+ modifier: Modifier = Modifier,
+ viewModel: DiscoverRequestViewModel =
+ hiltViewModel(
+ creationCallback = { it.create(destination.type, destination.startIndex) },
+ ),
+) {
+ val state by viewModel.state.collectAsState()
+ when (val s = state.loading) {
+ DataLoadingState.Pending,
+ DataLoadingState.Loading,
+ -> {
+ LoadingPage(modifier)
+ }
+
+ is DataLoadingState.Error -> {
+ ErrorMessage(s, modifier)
+ }
+
+ is DataLoadingState.Success> -> {
+ val gridFocusRequester = remember { FocusRequester() }
+ LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() }
+ Column(
+ modifier = modifier,
+ ) {
+ GridTitle(stringResource(destination.type.stringRes))
+
+ CardGrid(
+ initialPosition = destination.startIndex,
+ pager = s.data,
+ onClickItem = { index, item ->
+ viewModel.navigationManager.navigateTo(Destination.DiscoveredItem(item))
+ },
+ onLongClickItem = { index, item -> },
+ onClickPlay = { _, _ -> },
+ letterPosition = { 0 },
+ gridFocusRequester = gridFocusRequester,
+ showJumpButtons = false,
+ showLetterButtons = false,
+ cardContent = { item, onClick, onLongClick, mod ->
+ DiscoverItemCard(
+ item = item,
+ onClick = onClick,
+ onLongClick = onLongClick,
+ modifier = mod,
+ width = Dp.Unspecified,
+ )
+ },
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRow.kt
new file mode 100644
index 00000000..806a816a
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRow.kt
@@ -0,0 +1,194 @@
+package com.github.damontecres.wholphin.ui.discover
+
+import androidx.compose.foundation.focusGroup
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusProperties
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.focusRestorer
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.data.model.DiscoverItem
+import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
+import com.github.damontecres.wholphin.ui.cards.DiscoverViewMoreCard
+import com.github.damontecres.wholphin.ui.cards.ItemRowTitle
+import com.github.damontecres.wholphin.ui.components.ErrorMessage
+import com.github.damontecres.wholphin.ui.ifElse
+import com.github.damontecres.wholphin.ui.rememberInt
+import com.github.damontecres.wholphin.ui.tryRequestFocus
+import com.github.damontecres.wholphin.util.DataLoadingState
+
+@Composable
+fun DiscoverRow(
+ row: DiscoverRowData,
+ onClickItem: (Int, DiscoverItem) -> Unit,
+ onLongClickItem: (Int, DiscoverItem) -> Unit,
+ onCardFocus: (Int) -> Unit,
+ focusRequester: FocusRequester,
+ modifier: Modifier = Modifier,
+ enableViewMore: Boolean = false,
+ onClickViewMore: () -> Unit = {},
+) {
+ when (val state = row.items) {
+ is DataLoadingState.Error -> {
+ ErrorMessage(state.message, state.exception, modifier)
+ }
+
+ DataLoadingState.Loading,
+ DataLoadingState.Pending,
+ -> {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = modifier,
+ ) {
+ Text(
+ text = row.title,
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onBackground,
+ )
+ Text(
+ text = stringResource(R.string.loading),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onBackground,
+ )
+ }
+ }
+
+ is DataLoadingState.Success> -> {
+ DiscoverItemRow(
+ title = row.title,
+ items = state.data,
+ onClickItem = onClickItem,
+ onLongClickItem = onLongClickItem,
+ onCardFocus = onCardFocus,
+ enableViewMore = enableViewMore,
+ onClickViewMore = onClickViewMore,
+ modifier = modifier.focusRequester(focusRequester),
+ )
+ }
+ }
+}
+
+@Composable
+fun DiscoverItemRow(
+ title: String,
+ items: List,
+ onClickItem: (Int, DiscoverItem) -> Unit,
+ onLongClickItem: (Int, DiscoverItem) -> Unit,
+ onCardFocus: (Int) -> Unit,
+ enableViewMore: Boolean,
+ modifier: Modifier = Modifier,
+ horizontalPadding: Dp = 16.dp,
+ onClickViewMore: () -> Unit = {},
+) {
+ val state = rememberLazyListState()
+ val firstFocus = remember { FocusRequester() }
+ val focusRequester = remember { FocusRequester() }
+ var position by rememberInt()
+
+ val currentOnClickItem by rememberUpdatedState(onClickItem)
+ val currentOnLongClickItem by rememberUpdatedState(onLongClickItem)
+ val currentOnCardFocus by rememberUpdatedState(onCardFocus)
+
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier =
+ modifier.focusProperties {
+ onEnter = {
+ focusRequester.tryRequestFocus()
+ }
+ },
+ ) {
+ ItemRowTitle(title)
+
+ LazyRow(
+ state = state,
+ horizontalArrangement = Arrangement.spacedBy(horizontalPadding),
+ contentPadding = PaddingValues(horizontal = horizontalPadding, vertical = 8.dp),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .focusGroup()
+ .focusRestorer(firstFocus)
+ .focusRequester(focusRequester),
+ ) {
+ itemsIndexed(items) { index, item ->
+ val cardModifier =
+ remember(index, position) {
+ if (index == position) {
+ Modifier.focusRequester(firstFocus)
+ } else {
+ Modifier
+ }.onFocusChanged {
+ if (it.isFocused) {
+ currentOnCardFocus.invoke(index)
+ }
+ }
+ }
+
+ 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)
+ }
+ }
+
+ DiscoverItemCard(
+ item = item,
+ onClick = onClick,
+ onLongClick = onLongClick,
+ showOverlay = true,
+ modifier = cardModifier,
+ )
+ }
+ if (enableViewMore) {
+ item {
+ DiscoverViewMoreCard(
+ onClick =
+ remember {
+ {
+ position = items.size
+ onClickViewMore.invoke()
+ }
+ },
+ onLongClick = {},
+ modifier =
+ Modifier
+ .ifElse(items.size == position, Modifier.focusRequester(firstFocus))
+ .onFocusChanged {
+ if (it.isFocused) {
+ currentOnCardFocus.invoke(items.size)
+ }
+ },
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt
index 97b974ff..6c5b299a 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt
@@ -23,18 +23,14 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
-import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
-import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalDensity
-import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import androidx.tv.material3.MaterialTheme
-import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.data.model.SeerrItemType
@@ -42,23 +38,22 @@ import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrService
-import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
-import com.github.damontecres.wholphin.ui.cards.ItemRow
-import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.listToDotString
import com.github.damontecres.wholphin.ui.main.HomePageHeader
+import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
import com.github.damontecres.wholphin.util.DataLoadingState
+import com.github.damontecres.wholphin.util.DiscoverRequestType
import com.google.common.cache.CacheBuilder
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
-import org.jellyfin.sdk.api.client.ApiClient
+import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
@@ -68,7 +63,6 @@ class SeerrDiscoverViewModel
@param:ApplicationContext private val context: Context,
private val seerrService: SeerrService,
val navigationManager: NavigationManager,
- private val api: ApiClient,
private val backdropService: BackdropService,
) : ViewModel() {
val state = MutableStateFlow(DiscoverState())
@@ -79,24 +73,53 @@ class SeerrDiscoverViewModel
backdropService.clearBackdrop()
}
fetchAndUpdateState(seerrService::discoverMovies) {
- this.copy(movies = DiscoverRowData(context.getString(R.string.movies), it))
+ this.copy(
+ movies =
+ DiscoverRowData(
+ context.getString(R.string.movies),
+ it,
+ DiscoverRequestType.DISCOVER_MOVIES,
+ ),
+ )
}
fetchAndUpdateState(seerrService::discoverTv) {
- this.copy(tv = DiscoverRowData(context.getString(R.string.tv_shows), it))
+ this.copy(
+ tv =
+ DiscoverRowData(
+ context.getString(R.string.tv_shows),
+ it,
+ DiscoverRequestType.DISCOVER_TV,
+ ),
+ )
}
fetchAndUpdateState(seerrService::trending) {
- this.copy(trending = DiscoverRowData(context.getString(R.string.trending), it))
+ this.copy(
+ trending =
+ DiscoverRowData(
+ context.getString(R.string.trending),
+ it,
+ DiscoverRequestType.TRENDING,
+ ),
+ )
}
fetchAndUpdateState(seerrService::upcomingMovies) {
this.copy(
upcomingMovies =
- DiscoverRowData(context.getString(R.string.upcoming_movies), it),
+ DiscoverRowData(
+ context.getString(R.string.upcoming_movies),
+ it,
+ DiscoverRequestType.UPCOMING_MOVIES,
+ ),
)
}
fetchAndUpdateState(seerrService::upcomingTv) {
this.copy(
upcomingTv =
- DiscoverRowData(context.getString(R.string.upcoming_tv), it),
+ DiscoverRowData(
+ context.getString(R.string.upcoming_tv),
+ it,
+ DiscoverRequestType.UPCOMING_TV,
+ ),
)
}
}
@@ -127,6 +150,8 @@ class SeerrDiscoverViewModel
if (item != null) {
backdropService.submit("discover_${item.id}", item.backDropUrl)
fetchRating(item)
+ } else {
+ backdropService.clearBackdrop()
}
}
}
@@ -145,26 +170,39 @@ class SeerrDiscoverViewModel
return@launchIO
}
val result =
- when (item.type) {
- SeerrItemType.MOVIE -> {
- DiscoverRating(
- seerrService.api.moviesApi.movieMovieIdRatingsGet(
- movieId = item.id,
- ),
- )
- }
+ try {
+ when (item.type) {
+ SeerrItemType.MOVIE -> {
+ DiscoverRating(
+ seerrService.api.moviesApi.movieMovieIdRatingsGet(
+ movieId = item.id,
+ ),
+ )
+ }
- SeerrItemType.TV -> {
- DiscoverRating(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id))
- }
+ SeerrItemType.TV -> {
+ DiscoverRating(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id))
+ }
- SeerrItemType.PERSON -> {
- DiscoverRating(null, null)
- }
-
- SeerrItemType.UNKNOWN -> {
+ SeerrItemType.PERSON -> {
+ DiscoverRating(null, null)
+ }
+
+ SeerrItemType.UNKNOWN -> {
+ DiscoverRating(null, null)
+ }
+ }
+ } catch (ex: ClientException) {
+ if (ex.statusCode == 404) {
+ Timber.w("No rating found for %s", item.id)
DiscoverRating(null, null)
+ } else {
+ Timber.e(ex, "Error getting rating for %s", item.id)
+ return@launchIO
}
+ } catch (ex: Exception) {
+ Timber.e(ex, "Error getting rating for %s", item.id)
+ return@launchIO
}
ratingCache.put(item.id, result)
rating.update {
@@ -177,9 +215,10 @@ class SeerrDiscoverViewModel
data class DiscoverRowData(
val title: String,
val items: DataLoadingState>,
+ val type: DiscoverRequestType,
) {
companion object {
- val EMPTY = DiscoverRowData("", DataLoadingState.Pending)
+ val EMPTY = DiscoverRowData("", DataLoadingState.Pending, DiscoverRequestType.UNKNOWN)
}
}
@@ -250,6 +289,8 @@ fun SeerrDiscoverPage(
overviewTwoLines = true,
quickDetails = details,
timeRemaining = null,
+ showLogo = preferences.appPreferences.interfacePreferences.showLogos,
+ logoImageUrl = null, // TODO
modifier =
Modifier
.padding(top = 24.dp, bottom = 16.dp, start = 32.dp)
@@ -284,6 +325,15 @@ fun SeerrDiscoverPage(
onLongClickItem = { index, item -> },
onCardFocus = { index -> position = RowColumn(rowIndex, index) },
focusRequester = focusRequesters[rowIndex],
+ enableViewMore = row.type != DiscoverRequestType.UNKNOWN,
+ onClickViewMore = {
+ (row.items as? DataLoadingState.Success>)?.data?.size?.let {
+ position = RowColumn(rowIndex, it)
+ }
+ viewModel.navigationManager.navigateTo(
+ Destination.DiscoverMoreResult(row.type),
+ )
+ },
modifier =
Modifier
.fillMaxWidth(),
@@ -294,63 +344,3 @@ fun SeerrDiscoverPage(
}
}
}
-
-@Composable
-fun DiscoverRow(
- row: DiscoverRowData,
- onClickItem: (Int, DiscoverItem) -> Unit,
- onLongClickItem: (Int, DiscoverItem) -> Unit,
- onCardFocus: (Int) -> Unit,
- focusRequester: FocusRequester,
- modifier: Modifier = Modifier,
-) {
- when (val state = row.items) {
- is DataLoadingState.Error -> {
- ErrorMessage(state.message, state.exception, modifier)
- }
-
- DataLoadingState.Loading,
- DataLoadingState.Pending,
- -> {
- Column(
- verticalArrangement = Arrangement.spacedBy(8.dp),
- modifier = modifier,
- ) {
- Text(
- text = row.title,
- style = MaterialTheme.typography.titleLarge,
- color = MaterialTheme.colorScheme.onBackground,
- )
- Text(
- text = stringResource(R.string.loading),
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.onBackground,
- )
- }
- }
-
- is DataLoadingState.Success> -> {
- ItemRow(
- title = row.title,
- items = state.data,
- onClickItem = onClickItem,
- onLongClickItem = onLongClickItem,
- cardContent = { index: Int, item: DiscoverItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
- DiscoverItemCard(
- item = item,
- onClick = onClick,
- onLongClick = onLongClick,
- showOverlay = false,
- modifier =
- mod.onFocusChanged {
- if (it.isFocused) {
- onCardFocus.invoke(index)
- }
- },
- )
- },
- modifier = modifier.focusRequester(focusRequester),
- )
- }
- }
-}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt
index 3a7a457a..ef0fdbad 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt
@@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -27,6 +26,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -39,7 +39,6 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
-import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@@ -47,6 +46,7 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.data.model.HomeRowConfig
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.Cards
@@ -54,6 +54,7 @@ import com.github.damontecres.wholphin.ui.cards.BannerCard
import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle
import com.github.damontecres.wholphin.ui.cards.GenreCard
import com.github.damontecres.wholphin.ui.cards.ItemRow
+import com.github.damontecres.wholphin.ui.cards.StudioCard
import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
import com.github.damontecres.wholphin.ui.components.DialogParams
@@ -61,9 +62,12 @@ import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.EpisodeName
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.FocusableItemRow
+import com.github.damontecres.wholphin.ui.components.HeaderUtils
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.QuickDetails
import com.github.damontecres.wholphin.ui.components.RowColumnItem
+import com.github.damontecres.wholphin.ui.components.TitleOrLogo
+import com.github.damontecres.wholphin.ui.components.rememberLogoUrl
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
@@ -121,57 +125,82 @@ fun HomePage(
var showDeleteDialog by remember { mutableStateOf(null) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var position by rememberPosition()
+
+ val onFocusPosition = remember { { it: RowColumn -> position = it } }
+ val onClickItem =
+ remember {
+ { clickedPosition: RowColumn, item: BaseItem ->
+ position = clickedPosition
+ viewModel.navigationManager.navigateTo(item.destination())
+ }
+ }
+ val onLongClickItem =
+ remember {
+ { clickedPosition: RowColumn, item: BaseItem ->
+ position = clickedPosition
+ val row =
+ (homeRows.getOrNull(clickedPosition.row) as? HomeRowLoadingState.Success)
+ val canRemoveContinueWatching =
+ row?.rowType is HomeRowConfig.ContinueWatching || row?.rowType is HomeRowConfig.ContinueWatchingCombined
+ val canRemoveNextUp =
+ row?.rowType is HomeRowConfig.NextUp || row?.rowType is HomeRowConfig.ContinueWatchingCombined
+ val dialogItems =
+ buildMoreDialogItemsForHome(
+ context = context,
+ item = item,
+ seriesId = item.data.seriesId,
+ playbackPosition = item.playbackPosition,
+ watched = item.played,
+ favorite = item.favorite,
+ canDelete = viewModel.canDelete(item, preferences.appPreferences),
+ canRemoveNextUp = canRemoveNextUp,
+ canRemoveContinueWatching = canRemoveContinueWatching,
+ actions =
+ MoreDialogActions(
+ navigateTo = viewModel.navigationManager::navigateTo,
+ onClickWatch = { itemId, played ->
+ viewModel.setWatched(itemId, played)
+ },
+ onClickFavorite = { itemId, favorite ->
+ viewModel.setFavorite(itemId, favorite)
+ },
+ onClickAddPlaylist = { itemId ->
+ playlistViewModel.loadPlaylists(MediaType.VIDEO)
+ showPlaylistDialog = itemId
+ },
+ onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
+ onClickDelete = {
+ showDeleteDialog = RowColumnItem(position, item)
+ },
+ onClickRemoveFromNextUp = viewModel::removeFromNextUp,
+ ),
+ )
+ dialog =
+ DialogParams(
+ title = item.title ?: "",
+ fromLongClick = true,
+ items = dialogItems,
+ )
+ }
+ }
+ val onClickPlay =
+ remember {
+ { _: RowColumn, item: BaseItem ->
+ viewModel.navigationManager.navigateTo(Destination.Playback(item))
+ }
+ }
+
HomePageContent(
homeRows = homeRows,
position = position,
- onFocusPosition = { position = it },
- onClickItem = { clickedPosition, item ->
- position = clickedPosition
- viewModel.navigationManager.navigateTo(item.destination())
- },
- onLongClickItem = { clickedPosition, item ->
- position = clickedPosition
- val dialogItems =
- buildMoreDialogItemsForHome(
- context = context,
- item = item,
- seriesId = item.data.seriesId,
- playbackPosition = item.playbackPosition,
- watched = item.played,
- favorite = item.favorite,
- canDelete = viewModel.canDelete(item, preferences.appPreferences),
- actions =
- MoreDialogActions(
- navigateTo = viewModel.navigationManager::navigateTo,
- onClickWatch = { itemId, played ->
- viewModel.setWatched(itemId, played)
- },
- onClickFavorite = { itemId, favorite ->
- viewModel.setFavorite(itemId, favorite)
- },
- onClickAddPlaylist = { itemId ->
- playlistViewModel.loadPlaylists(MediaType.VIDEO)
- showPlaylistDialog = itemId
- },
- onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
- onClickDelete = {
- showDeleteDialog = RowColumnItem(position, item)
- },
- ),
- )
- dialog =
- DialogParams(
- title = item.title ?: "",
- fromLongClick = true,
- items = dialogItems,
- )
- },
- onClickPlay = { _, item ->
- viewModel.navigationManager.navigateTo(Destination.Playback(item))
- },
+ onFocusPosition = onFocusPosition,
+ onClickItem = onClickItem,
+ onLongClickItem = onLongClickItem,
+ onClickPlay = onClickPlay,
loadingState = refreshing,
showClock = preferences.appPreferences.interfacePreferences.showClock,
onUpdateBackdrop = viewModel::updateBackdrop,
+ showLogo = preferences.appPreferences.interfacePreferences.showLogos,
modifier = modifier,
)
dialog?.let { params ->
@@ -208,6 +237,8 @@ fun HomePage(
)
}
}
+
+ else -> {}
}
}
@@ -222,19 +253,34 @@ fun HomePageContent(
onClickPlay: (RowColumn, BaseItem) -> Unit,
showClock: Boolean,
onUpdateBackdrop: (BaseItem) -> Unit,
+ showLogo: Boolean,
modifier: Modifier = Modifier,
loadingState: LoadingState? = null,
listState: LazyListState = rememberLazyListState(),
takeFocus: Boolean = true,
showEmptyRows: Boolean = false,
+ headerComposable: @Composable (focusedItem: BaseItem?) -> Unit = { focusedItem ->
+ HomePageHeader(
+ item = focusedItem,
+ showLogo = showLogo,
+ modifier = HeaderUtils.modifier,
+ )
+ },
) {
val focusedItem =
- position.let {
- (homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column)
+ remember(homeRows, position) {
+ (homeRows.getOrNull(position.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(
+ position.column,
+ )
}
- val rowFocusRequesters = remember(homeRows) { List(homeRows.size) { FocusRequester() } }
+ val rowFocusRequesters = remember(homeRows.size) { List(homeRows.size) { FocusRequester() } }
var firstFocused by remember { mutableStateOf(false) }
+
+ val currentPosition by rememberUpdatedState(position)
+ val currentOnFocusPosition by rememberUpdatedState(onFocusPosition)
+ val currentOnClickPlay by rememberUpdatedState(onClickPlay)
+
if (takeFocus) {
LaunchedEffect(homeRows) {
if (!firstFocused && homeRows.isNotEmpty()) {
@@ -256,28 +302,20 @@ fun HomePageContent(
}
}
}
- LaunchedEffect(position) {
- if (position.row >= 0) {
- listState.animateScrollToItem(position.row)
- }
- }
LaunchedEffect(onUpdateBackdrop, focusedItem) {
focusedItem?.let { onUpdateBackdrop.invoke(it) }
}
Box(modifier = modifier) {
Column(modifier = Modifier.fillMaxSize()) {
- HomePageHeader(
- item = focusedItem,
- modifier =
- Modifier
- .padding(top = 48.dp, bottom = 32.dp, start = 8.dp)
- .fillMaxHeight(.33f),
- )
+ headerComposable.invoke(focusedItem)
+
val density = LocalDensity.current
val spaceAbovePx =
- with(density) {
- // The size of the row titles & spacing
- 50.dp.toPx()
+ remember(density) {
+ with(density) {
+ // The size of the row titles & spacing
+ 50.dp.toPx()
+ }
}
val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current
CompositionLocalProvider(
@@ -324,15 +362,27 @@ fun HomePageContent(
ItemRow(
title = row.title,
items = row.items,
- onClickItem = { index, item ->
- onClickItem.invoke(RowColumn(rowIndex, index), item)
- },
- onLongClickItem = { index, item ->
- onLongClickItem.invoke(
- RowColumn(rowIndex, index),
- item,
- )
- },
+ onClickItem =
+ remember(rowIndex, onClickItem) {
+ { index, item ->
+ onClickItem.invoke(
+ RowColumn(
+ rowIndex,
+ index,
+ ),
+ item,
+ )
+ }
+ },
+ onLongClickItem =
+ remember(rowIndex, onLongClickItem) {
+ { index, item ->
+ onLongClickItem.invoke(
+ RowColumn(rowIndex, index),
+ item,
+ )
+ }
+ },
modifier =
Modifier
.fillMaxWidth()
@@ -341,6 +391,34 @@ fun HomePageContent(
.animateItem(),
horizontalPadding = viewOptions.spacing.dp,
cardContent = { index, item, cardModifier, onClick, onLongClick ->
+ val onFocus =
+ remember(rowIndex, index) {
+ { isFocused: Boolean ->
+ if (isFocused) {
+ currentOnFocusPosition(
+ RowColumn(
+ rowIndex,
+ index,
+ ),
+ )
+ }
+ }
+ }
+ val onKey =
+ remember(item) {
+ { event: androidx.compose.ui.input.key.KeyEvent ->
+ if (isPlayKeyUp(event) && item?.type?.playable == true) {
+ Timber.v("Clicked play on ${item.id}")
+ currentOnClickPlay(
+ currentPosition,
+ item,
+ )
+ true
+ } else {
+ false
+ }
+ }
+ }
HomePageCardContent(
index = index,
item = item,
@@ -349,23 +427,8 @@ fun HomePageContent(
viewOptions = viewOptions,
modifier =
cardModifier
- .onFocusChanged {
- if (it.isFocused) {
- onFocusPosition?.invoke(
- RowColumn(rowIndex, index),
- )
- }
- }.onKeyEvent {
- if (isPlayKeyUp(it) && item?.type?.playable == true) {
- Timber.v("Clicked play on ${item.id}")
- onClickPlay.invoke(
- position,
- item,
- )
- return@onKeyEvent true
- }
- return@onKeyEvent false
- },
+ .onFocusChanged { onFocus(it.isFocused) }
+ .onKeyEvent { onKey(it) },
)
},
)
@@ -406,6 +469,7 @@ fun HomePageContent(
@Composable
fun HomePageHeader(
item: BaseItem?,
+ showLogo: Boolean,
modifier: Modifier = Modifier,
) {
val isEpisode = item?.type == BaseItemKind.EPISODE
@@ -417,6 +481,8 @@ fun HomePageHeader(
overviewTwoLines = isEpisode,
quickDetails = item?.ui?.quickDetails ?: AnnotatedString(""),
timeRemaining = item?.timeRemainingOrRuntime,
+ showLogo = showLogo,
+ logoImageUrl = rememberLogoUrl(item),
modifier = modifier,
)
}
@@ -429,31 +495,28 @@ fun HomePageHeader(
overviewTwoLines: Boolean,
quickDetails: AnnotatedString?,
timeRemaining: Duration?,
+ showLogo: Boolean,
+ logoImageUrl: String?,
modifier: Modifier = Modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier,
) {
- title?.let {
- Text(
- text = it,
- style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
- color = MaterialTheme.colorScheme.onBackground,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.fillMaxWidth(.75f),
- )
- }
+ TitleOrLogo(
+ title = title,
+ logoImageUrl = logoImageUrl,
+ showLogo = showLogo,
+ modifier = Modifier.fillMaxWidth(.75f),
+ )
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
- .fillMaxWidth(.6f)
- .fillMaxHeight(),
+ .fillMaxWidth(.6f),
) {
- subtitle?.let {
- EpisodeName(it)
+ if (subtitle != null) {
+ EpisodeName(subtitle)
}
QuickDetails(quickDetails ?: AnnotatedString(""), timeRemaining)
val overviewModifier =
@@ -498,6 +561,17 @@ fun HomePageCardContent(
)
}
+ BaseItemKind.STUDIO -> {
+ StudioCard(
+ studioId = item.id,
+ name = item.name,
+ imageUrl = item.imageUrlOverride,
+ onClick = onClick,
+ onLongClick = onLongClick,
+ modifier = modifier.height(viewOptions.heightDp.dp),
+ )
+ }
+
else -> {
val imageType =
remember(item, viewOptions) {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt
index 53417863..6d1517c2 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt
@@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.services.DatePlayedService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.HomePageResolvedSettings
import com.github.damontecres.wholphin.services.HomeSettingsService
+import com.github.damontecres.wholphin.services.LatestNextUpService
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.NavDrawerService
@@ -39,6 +40,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
+import org.jellyfin.sdk.model.api.BaseItemKind
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
@@ -58,6 +60,7 @@ class HomeViewModel
private val backdropService: BackdropService,
private val userPreferencesService: UserPreferencesService,
private val mediaManagementService: MediaManagementService,
+ private val latestNextUpService: LatestNextUpService,
) : ViewModel() {
private val _state = MutableStateFlow(HomeState.EMPTY)
val state: StateFlow = _state
@@ -85,7 +88,13 @@ class HomeViewModel
val refresh =
state.loadingState == LoadingState.Success && state.settings == settings
Timber.v("refresh=$refresh, state.loadingState=${state.loadingState}")
- _state.update { it.copy(settings = settings) }
+ _state.update {
+ it.copy(
+ loadingState = if (refresh) LoadingState.Success else LoadingState.Loading,
+ refreshState = LoadingState.Loading,
+ settings = settings,
+ )
+ }
val semaphore = Semaphore(4)
@@ -225,6 +234,19 @@ class HomeViewModel
item: BaseItem,
appPreferences: AppPreferences,
): Boolean = mediaManagementService.canDelete(item, appPreferences)
+
+ fun removeFromNextUp(item: BaseItem) {
+ if (item.type == BaseItemKind.EPISODE) {
+ viewModelScope.launchDefault {
+ serverRepository.currentUser.value?.id?.let { userId ->
+ latestNextUpService.removeFromNextUp(userId, item)
+ init()
+ }
+ }
+ } else {
+ Timber.w("Item is not an episode %s", item.id)
+ }
+ }
}
data class HomeState(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt
index a7fade4e..2c4f729e 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt
@@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.ui.main
import androidx.activity.compose.BackHandler
+import androidx.annotation.StringRes
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -32,7 +33,6 @@ import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
@@ -51,6 +51,7 @@ import com.github.damontecres.wholphin.data.model.SeerrItemType
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrService
+import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
@@ -76,6 +77,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Semaphore
+import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemKind
@@ -100,10 +103,15 @@ class SearchViewModel
val series = MutableLiveData(SearchResult.NoQuery)
val episodes = MutableLiveData(SearchResult.NoQuery)
val collections = MutableLiveData(SearchResult.NoQuery)
+ val albums = MutableLiveData(SearchResult.NoQuery)
+ val artists = MutableLiveData(SearchResult.NoQuery)
+ val songs = MutableLiveData(SearchResult.NoQuery)
val seerrResults = MutableLiveData(SearchResult.NoQuery)
private var currentQuery: String? = null
+ private val semaphore = Semaphore(4)
+
fun search(query: String?) {
if (currentQuery == query) {
return
@@ -117,6 +125,9 @@ class SearchViewModel
searchInternal(query, BaseItemKind.MOVIE, movies)
searchInternal(query, BaseItemKind.SERIES, series)
searchInternal(query, BaseItemKind.EPISODE, episodes)
+ searchInternal(query, BaseItemKind.MUSIC_ALBUM, albums)
+ searchInternal(query, BaseItemKind.MUSIC_ARTIST, artists)
+ searchInternal(query, BaseItemKind.AUDIO, songs)
searchInternal(query, BaseItemKind.BOX_SET, collections)
searchSeerr(query)
} else {
@@ -135,19 +146,21 @@ class SearchViewModel
) {
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
try {
- val request =
- GetItemsRequest(
- searchTerm = query,
- recursive = true,
- includeItemTypes = listOf(type),
- fields = SlimItemFields,
- limit = 25,
- )
- val pager =
- ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope)
- pager.init()
- withContext(Dispatchers.Main) {
- target.value = SearchResult.Success(pager)
+ semaphore.withPermit {
+ val request =
+ GetItemsRequest(
+ searchTerm = query,
+ recursive = true,
+ includeItemTypes = listOf(type),
+ fields = SlimItemFields,
+ limit = 25,
+ )
+ val pager =
+ ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope)
+ pager.init()
+ withContext(Dispatchers.Main) {
+ target.value = SearchResult.Success(pager)
+ }
}
} catch (ex: Exception) {
Timber.e(ex, "Exception searching for $type")
@@ -202,10 +215,13 @@ sealed interface SearchResult {
private const val SEARCH_ROW = 0
private const val MOVIE_ROW = SEARCH_ROW + 1
-private const val COLLECTION_ROW = MOVIE_ROW + 1
-private const val SERIES_ROW = COLLECTION_ROW + 1
+private const val SERIES_ROW = MOVIE_ROW + 1
private const val EPISODE_ROW = SERIES_ROW + 1
-private const val SEERR_ROW = EPISODE_ROW + 1
+private const val ALBUM_ROW = EPISODE_ROW + 1
+private const val ARTIST_ROW = ALBUM_ROW + 1
+private const val SONG_ROW = ARTIST_ROW + 1
+private const val COLLECTION_ROW = SONG_ROW + 1
+private const val SEERR_ROW = COLLECTION_ROW + 1
/** Delay for focus to settle after voice search dialog dismisses. */
private const val VOICE_RESULT_FOCUS_DELAY_MS = 350L
@@ -216,13 +232,15 @@ fun SearchPage(
modifier: Modifier = Modifier,
viewModel: SearchViewModel = hiltViewModel(),
) {
- val context = LocalContext.current
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
val movies by viewModel.movies.observeAsState(SearchResult.NoQuery)
val collections by viewModel.collections.observeAsState(SearchResult.NoQuery)
val series by viewModel.series.observeAsState(SearchResult.NoQuery)
val episodes by viewModel.episodes.observeAsState(SearchResult.NoQuery)
+ val albums by viewModel.albums.observeAsState(SearchResult.NoQuery)
+ val artists by viewModel.artists.observeAsState(SearchResult.NoQuery)
+ val songs by viewModel.songs.observeAsState(SearchResult.NoQuery)
val seerrResults by viewModel.seerrResults.observeAsState(SearchResult.NoQuery)
// val query = rememberTextFieldState()
@@ -358,7 +376,7 @@ fun SearchPage(
}
}
searchResultRow(
- title = context.getString(R.string.movies),
+ title = R.string.movies,
result = movies,
rowIndex = MOVIE_ROW,
position = position,
@@ -368,17 +386,7 @@ fun SearchPage(
modifier = Modifier.fillMaxWidth(),
)
searchResultRow(
- title = context.getString(R.string.collections),
- result = collections,
- rowIndex = COLLECTION_ROW,
- position = position,
- focusRequester = focusRequesters[COLLECTION_ROW],
- onClickItem = onClickItem,
- onClickPosition = { position = it },
- modifier = Modifier.fillMaxWidth(),
- )
- searchResultRow(
- title = context.getString(R.string.tv_shows),
+ title = R.string.tv_shows,
result = series,
rowIndex = SERIES_ROW,
position = position,
@@ -388,7 +396,7 @@ fun SearchPage(
modifier = Modifier.fillMaxWidth(),
)
searchResultRow(
- title = context.getString(R.string.episodes),
+ title = R.string.episodes,
result = episodes,
rowIndex = EPISODE_ROW,
position = position,
@@ -405,12 +413,95 @@ fun SearchPage(
},
onLongClick = onLongClick,
imageHeight = 140.dp,
+ showImageOverlay = true,
modifier = mod.padding(horizontal = 8.dp),
)
},
)
searchResultRow(
- title = context.getString(R.string.discover),
+ title = R.string.albums,
+ result = albums,
+ rowIndex = ALBUM_ROW,
+ position = position,
+ focusRequester = focusRequesters[ALBUM_ROW],
+ onClickItem = onClickItem,
+ onClickPosition = { position = it },
+ modifier = Modifier.fillMaxWidth(),
+ cardContent = { index, item, mod, onClick, onLongClick ->
+ SeasonCard(
+ item = item,
+ onClick = {
+ position = RowColumn(ALBUM_ROW, index)
+ onClick.invoke()
+ },
+ onLongClick = onLongClick,
+ imageHeight = Cards.heightEpisode,
+ aspectRatio = AspectRatios.SQUARE,
+ showImageOverlay = true,
+ modifier = mod,
+ )
+ },
+ )
+ searchResultRow(
+ title = R.string.artists,
+ result = artists,
+ rowIndex = COLLECTION_ROW,
+ position = position,
+ focusRequester = focusRequesters[COLLECTION_ROW],
+ onClickItem = onClickItem,
+ onClickPosition = { position = it },
+ modifier = Modifier.fillMaxWidth(),
+ cardContent = { index, item, mod, onClick, onLongClick ->
+ SeasonCard(
+ item = item,
+ onClick = {
+ position = RowColumn(ALBUM_ROW, index)
+ onClick.invoke()
+ },
+ onLongClick = onLongClick,
+ imageHeight = Cards.heightEpisode,
+ aspectRatio = AspectRatios.SQUARE,
+ showImageOverlay = true,
+ modifier = mod,
+ )
+ },
+ )
+ searchResultRow(
+ title = R.string.songs,
+ result = songs,
+ rowIndex = SONG_ROW,
+ position = position,
+ focusRequester = focusRequesters[SONG_ROW],
+ onClickItem = onClickItem,
+ onClickPosition = { position = it },
+ modifier = Modifier.fillMaxWidth(),
+ cardContent = { index, item, mod, onClick, onLongClick ->
+ SeasonCard(
+ item = item,
+ onClick = {
+ position = RowColumn(ALBUM_ROW, index)
+ onClick.invoke()
+ },
+ onLongClick = onLongClick,
+ imageHeight = Cards.heightEpisode,
+ aspectRatio = AspectRatios.SQUARE,
+ showImageOverlay = true,
+ modifier = mod,
+ )
+ },
+ )
+ searchResultRow(
+ title = R.string.collections,
+ result = collections,
+ rowIndex = COLLECTION_ROW,
+ position = position,
+ focusRequester = focusRequesters[COLLECTION_ROW],
+ onClickItem = onClickItem,
+ onClickPosition = { position = it },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ searchResultRow(
+ title = R.string.discover,
result = seerrResults,
rowIndex = SEERR_ROW,
position = position,
@@ -437,7 +528,7 @@ fun SearchPage(
}
fun LazyListScope.searchResultRow(
- title: String,
+ @StringRes title: Int,
result: SearchResult,
rowIndex: Int,
position: RowColumn,
@@ -461,6 +552,7 @@ fun LazyListScope.searchResultRow(
},
onLongClick = onLongClick,
imageHeight = Cards.height2x3,
+ showImageOverlay = true,
modifier = mod,
)
},
@@ -469,7 +561,7 @@ fun LazyListScope.searchResultRow(
when (val r = result) {
is SearchResult.Error -> {
SearchResultPlaceholder(
- title = title,
+ title = stringResource(title),
message = r.ex.localizedMessage ?: "Error occurred during search",
messageColor = MaterialTheme.colorScheme.error,
modifier = Modifier,
@@ -482,7 +574,7 @@ fun LazyListScope.searchResultRow(
SearchResult.Searching -> {
SearchResultPlaceholder(
- title = title,
+ title = stringResource(title),
message = stringResource(R.string.searching),
modifier = modifier,
)
@@ -491,13 +583,13 @@ fun LazyListScope.searchResultRow(
is SearchResult.Success -> {
if (r.items.isEmpty()) {
SearchResultPlaceholder(
- title = title,
+ title = stringResource(title),
message = stringResource(R.string.no_results),
modifier = modifier,
)
} else {
ItemRow(
- title = title,
+ title = stringResource(title),
items = r.items,
onClickItem = onClickItem,
onLongClickItem = { _, _ -> },
@@ -510,13 +602,13 @@ fun LazyListScope.searchResultRow(
is SearchResult.SuccessSeerr -> {
if (r.items.isEmpty()) {
SearchResultPlaceholder(
- title = title,
+ title = stringResource(title),
message = stringResource(R.string.no_results),
modifier = modifier,
)
} else {
ItemRow(
- title = title,
+ title = stringResource(title),
items = r.items,
onClickItem = { index, item ->
onClickPosition.invoke(RowColumn(rowIndex, index))
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt
index 4236c03a..8c603911 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt
@@ -80,6 +80,16 @@ fun getSupportedRowTypes(library: Library): List {
)
}
+ library.collectionType == CollectionType.TVSHOWS -> {
+ listOf(
+ LibraryRowType.RECENTLY_ADDED,
+ LibraryRowType.RECENTLY_RELEASED,
+ LibraryRowType.SUGGESTIONS,
+ LibraryRowType.GENRES,
+ LibraryRowType.STUDIOS,
+ )
+ }
+
supportsSuggestions -> {
listOf(
LibraryRowType.RECENTLY_ADDED,
@@ -124,6 +134,7 @@ enum class LibraryRowType(
RECENTLY_RELEASED(R.string.recently_released),
SUGGESTIONS(R.string.suggestions),
GENRES(R.string.genres),
+ STUDIOS(R.string.studios),
TV_CHANNELS(R.string.channels),
TV_PROGRAMS(R.string.live_tv),
RECENTLY_RECORDED(R.string.recently_recorded),
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt
index eaf44d81..5714723e 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt
@@ -29,6 +29,7 @@ data class HomeRowPresets(
val tvLibrary: HomeRowViewOptions,
val videoLibrary: HomeRowViewOptions,
val photoLibrary: HomeRowViewOptions,
+ val musicLibrary: HomeRowViewOptions,
val playlist: HomeRowViewOptions,
val liveTv: HomeRowViewOptions,
val genreSize: Int,
@@ -51,8 +52,9 @@ data class HomeRowPresets(
CollectionType.LIVETV -> liveTv
+ CollectionType.MUSIC -> musicLibrary
+
CollectionType.UNKNOWN,
- CollectionType.MUSIC,
CollectionType.BOOKS,
CollectionType.PLAYLISTS,
CollectionType.FOLDERS,
@@ -74,6 +76,10 @@ data class HomeRowPresets(
aspectRatio = AspectRatio.WIDE,
contentScale = PrefContentScale.CROP,
),
+ musicLibrary =
+ HomeRowViewOptions(
+ aspectRatio = AspectRatio.SQUARE,
+ ),
playlist =
HomeRowViewOptions(
aspectRatio = AspectRatio.SQUARE,
@@ -111,6 +117,11 @@ data class HomeRowPresets(
aspectRatio = AspectRatio.WIDE,
contentScale = PrefContentScale.CROP,
),
+ musicLibrary =
+ HomeRowViewOptions(
+ heightDp = epHeight,
+ aspectRatio = AspectRatio.SQUARE,
+ ),
playlist =
HomeRowViewOptions(
heightDp = epHeight,
@@ -154,6 +165,11 @@ data class HomeRowPresets(
aspectRatio = AspectRatio.WIDE,
contentScale = PrefContentScale.CROP,
),
+ musicLibrary =
+ HomeRowViewOptions(
+ heightDp = epHeight,
+ aspectRatio = AspectRatio.SQUARE,
+ ),
playlist =
HomeRowViewOptions(
heightDp = epHeight,
@@ -198,6 +214,11 @@ data class HomeRowPresets(
aspectRatio = AspectRatio.WIDE,
contentScale = PrefContentScale.CROP,
),
+ musicLibrary =
+ HomeRowViewOptions(
+ heightDp = epHeight,
+ aspectRatio = AspectRatio.SQUARE,
+ ),
playlist =
HomeRowViewOptions(
heightDp = epHeight,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt
index 3bac590a..5bf3056d 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt
@@ -59,6 +59,8 @@ val favoriteOptions by lazy {
BaseItemKind.VIDEO to R.string.videos,
BaseItemKind.PLAYLIST to R.string.playlists,
BaseItemKind.PERSON to R.string.people,
+ BaseItemKind.MUSIC_ARTIST to R.string.artists,
+ BaseItemKind.MUSIC_ALBUM to R.string.albums,
)
}
val favoriteOptionsList by lazy { favoriteOptions.keys.toList() }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt
index e4b61881..35c17a7e 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt
@@ -38,6 +38,7 @@ fun HomeSettingsGlobal(
onClickLoad: () -> Unit,
onClickLoadWeb: () -> Unit,
onClickReset: () -> Unit,
+ onClickViewNextUp: () -> Unit,
modifier: Modifier = Modifier,
) {
val firstFocus: FocusRequester = remember { FocusRequester() }
@@ -123,6 +124,20 @@ fun HomeSettingsGlobal(
)
}
item { HorizontalDivider() }
+ item {
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = stringResource(R.string.view_removed_next_up),
+ leadingContent = {
+ Text(
+ text = stringResource(R.string.fa_eye),
+ fontFamily = FontAwesome,
+ )
+ },
+ onClick = onClickViewNextUp,
+ modifier = Modifier,
+ )
+ }
item {
HomeSettingsListItem(
selected = false,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt
index fb025c02..3ec26f92 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt
@@ -32,6 +32,8 @@ import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.HomeRowConfig
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.preferences.AppPreferences
+import com.github.damontecres.wholphin.preferences.UserPreferences
+import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.ConfirmDialog
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.detail.search.SearchForDialog
@@ -51,6 +53,7 @@ val settingsWidth = 360.dp
@Composable
fun HomeSettingsPage(
+ preferences: UserPreferences,
modifier: Modifier,
viewModel: HomeSettingsViewModel = hiltViewModel(),
) {
@@ -59,6 +62,7 @@ fun HomeSettingsPage(
val backStack = rememberNavBackStack(HomeSettingsDestination.RowList)
var showConfirmDialog by remember { mutableStateOf(null) }
var searchForDialog by remember { mutableStateOf(null) }
+ var showRemovedNextUpDialog by remember { mutableStateOf(false) }
val state by viewModel.state.collectAsState()
var position by rememberPosition(0, 0)
@@ -282,6 +286,9 @@ fun HomeSettingsPage(
addRow(false) { viewModel.resetToDefault() }
}
},
+ onClickViewNextUp = {
+ showRemovedNextUpDialog = true
+ },
modifier = destModifier,
)
}
@@ -310,6 +317,7 @@ fun HomeSettingsPage(
listState = listState,
takeFocus = false,
showEmptyRows = true,
+ showLogo = preferences.appPreferences.interfacePreferences.showLogos,
modifier =
Modifier
.fillMaxHeight()
@@ -337,6 +345,15 @@ fun HomeSettingsPage(
},
)
}
+ if (showRemovedNextUpDialog) {
+ BasicDialog(
+ onDismissRequest = { showRemovedNextUpDialog = false },
+ ) {
+ RemovedNextUpContent(
+ modifier = Modifier.padding(16.dp),
+ )
+ }
+ }
}
data class ShowConfirm(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt
index 67b57e28..c4874aa0 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt
@@ -33,6 +33,7 @@ import com.github.damontecres.wholphin.services.UnsupportedHomeSettingsVersionEx
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
import com.github.damontecres.wholphin.services.tvAccess
+import com.github.damontecres.wholphin.ui.AspectRatio
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.HomeRowLoadingState
@@ -122,19 +123,23 @@ class HomeSettingsViewModel
state.value
.let { state ->
state.rows
- .map { it.config }
.map { row ->
viewModelScope.async(Dispatchers.IO) {
semaphore.withPermit {
- homeSettingsService.fetchDataForRow(
- row = row,
- scope = viewModelScope,
- prefs = prefs,
- userDto = userDto,
- libraries = state.libraries,
- limit = limit,
- isRefresh = false,
- )
+ try {
+ homeSettingsService.fetchDataForRow(
+ row = row.config,
+ scope = viewModelScope,
+ prefs = prefs,
+ userDto = userDto,
+ libraries = state.libraries,
+ limit = limit,
+ isRefresh = false,
+ )
+ } catch (ex: Exception) {
+ Timber.e(ex, "Error on row %s", row)
+ HomeRowLoadingState.Error(row.title, exception = ex)
+ }
}
}
}
@@ -195,7 +200,12 @@ class HomeSettingsViewModel
viewModelScope.launchIO {
updateState {
val rows = it.rows.toMutableList().apply { removeAt(index) }
- val rowData = it.rowData.toMutableList().apply { removeAt(index) }
+ val rowData =
+ if (index in it.rowData.indices) {
+ it.rowData.toMutableList().apply { removeAt(index) }
+ } else {
+ it.rowData
+ }
it.copy(
rows = rows,
rowData = rowData,
@@ -258,6 +268,24 @@ class HomeSettingsViewModel
rowType: LibraryRowType,
): Job =
viewModelScope.launchIO {
+ val viewOptions =
+ when (library.collectionType) {
+ CollectionType.MUSIC -> {
+ HomeRowViewOptions(aspectRatio = AspectRatio.SQUARE)
+ }
+
+ CollectionType.HOMEVIDEOS,
+ CollectionType.MUSICVIDEOS,
+ -> {
+ HomeRowViewOptions(
+ aspectRatio = AspectRatio.WIDE,
+ )
+ }
+
+ else -> {
+ HomeRowViewOptions()
+ }
+ }
val id = idCounter++
val newRow =
when (rowType) {
@@ -267,7 +295,7 @@ class HomeSettingsViewModel
HomeRowConfigDisplay(
id = id,
title = title,
- config = RecentlyAdded(library.itemId),
+ config = RecentlyAdded(library.itemId, viewOptions),
)
}
@@ -282,7 +310,7 @@ class HomeSettingsViewModel
HomeRowConfigDisplay(
id = id,
title = title,
- config = RecentlyReleased(library.itemId),
+ config = RecentlyReleased(library.itemId, viewOptions),
)
}
@@ -295,13 +323,23 @@ class HomeSettingsViewModel
)
}
+ LibraryRowType.STUDIOS -> {
+ val title =
+ library.name.let { context.getString(R.string.studios_in, it) }
+ HomeRowConfigDisplay(
+ id = id,
+ title = title,
+ config = HomeRowConfig.Studios(library.itemId),
+ )
+ }
+
LibraryRowType.SUGGESTIONS -> {
val title =
library.name.let { context.getString(R.string.suggestions_for, it) }
HomeRowConfigDisplay(
id = id,
title = title,
- config = Suggestions(library.itemId),
+ config = Suggestions(library.itemId, viewOptions),
)
}
@@ -386,7 +424,7 @@ class HomeSettingsViewModel
config =
HomeRowConfig.ByParent(
parentId = parent.id,
- recursive = true,
+ recursive = false,
),
)
updateState {
@@ -662,6 +700,7 @@ class HomeSettingsViewModel
BaseItemKind.VIDEO -> preset.videoLibrary
BaseItemKind.PLAYLIST -> preset.playlist
BaseItemKind.PERSON -> preset.movieLibrary
+ BaseItemKind.MUSIC_ARTIST, BaseItemKind.MUSIC_ALBUM -> preset.musicLibrary
else -> preset.movieLibrary
}
it.config.updateViewOptions(viewOptions)
@@ -671,6 +710,10 @@ class HomeSettingsViewModel
it.config.updateViewOptions(it.config.viewOptions.copy(heightDp = preset.genreSize))
}
+ is HomeRowConfig.Studios -> {
+ it.config.updateViewOptions(it.config.viewOptions.copy(heightDp = preset.genreSize))
+ }
+
is HomeRowConfig.GetItems -> {
it.config
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/RemovedNextUpContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/RemovedNextUpContent.kt
new file mode 100644
index 00000000..605f76d5
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/RemovedNextUpContent.kt
@@ -0,0 +1,259 @@
+package com.github.damontecres.wholphin.ui.main.settings
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.asFlow
+import androidx.lifecycle.viewModelScope
+import androidx.tv.material3.Icon
+import androidx.tv.material3.ListItem
+import androidx.tv.material3.ListItemDefaults
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import coil3.compose.AsyncImage
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.data.ServerRepository
+import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.services.ImageUrlService
+import com.github.damontecres.wholphin.services.LatestNextUpService
+import com.github.damontecres.wholphin.ui.components.Button
+import com.github.damontecres.wholphin.ui.components.ErrorMessage
+import com.github.damontecres.wholphin.ui.components.LoadingPage
+import com.github.damontecres.wholphin.ui.formatDateTime
+import com.github.damontecres.wholphin.ui.launchDefault
+import com.github.damontecres.wholphin.ui.toBaseItems
+import com.github.damontecres.wholphin.ui.tryRequestFocus
+import com.github.damontecres.wholphin.util.DataLoadingState
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import org.jellyfin.sdk.api.client.ApiClient
+import org.jellyfin.sdk.api.client.extensions.itemsApi
+import org.jellyfin.sdk.model.api.ImageType
+import timber.log.Timber
+import java.time.LocalDateTime
+import javax.inject.Inject
+
+@HiltViewModel
+class RemovedNextUpContentViewModel
+ @Inject
+ constructor(
+ private val api: ApiClient,
+ private val serverRepository: ServerRepository,
+ private val latestNextUpService: LatestNextUpService,
+ private val imageUrlService: ImageUrlService,
+ ) : ViewModel() {
+ private val mutex = Mutex()
+
+ private val _state = MutableStateFlow(RemovedNextUpState())
+ val state: StateFlow = _state
+
+ init {
+ viewModelScope.launchDefault {
+ serverRepository.currentUser.asFlow().collectLatest { user ->
+ _state.update { RemovedNextUpState() }
+ if (user == null) {
+ return@collectLatest
+ }
+ try {
+ val removed = latestNextUpService.getRemovedFromNextUp(user.id)
+ val series = mutableListOf()
+ removed.keys.chunked(50).forEach { ids ->
+ val results =
+ api.itemsApi
+ .getItems(
+ userId = user.id,
+ ids = ids,
+ ).toBaseItems(api, false)
+ results.forEach {
+ val imageUrl = imageUrlService.getItemImageUrl(it, ImageType.PRIMARY)
+ series.add(RemovedItem(it, imageUrl, removed[it.id]!!))
+ }
+ }
+ _state.update { it.copy(loading = DataLoadingState.Success(series)) }
+ } catch (ex: Exception) {
+ Timber.e(ex, "Error fetching removed series")
+ _state.update { it.copy(loading = DataLoadingState.Error(ex)) }
+ }
+ }
+ }
+ }
+
+ fun remove(item: RemovedItem) {
+ serverRepository.currentUser.value?.let { user ->
+ viewModelScope.launchDefault {
+ mutex.withLock {
+ _state.update { it.copy(removedEnabled = false) }
+ try {
+ latestNextUpService.allowSeriesRemovedFromNextUp(user.id, item.series.id)
+ val newItems =
+ (_state.value.loading as? DataLoadingState.Success>)
+ ?.data
+ ?.toMutableList()
+ ?.apply {
+ removeIf { it.series.id == item.series.id }
+ }
+ val loading =
+ if (newItems != null) {
+ DataLoadingState.Success(newItems)
+ } else {
+ DataLoadingState.Error("Error occurred")
+ }
+ _state.update {
+ it.copy(
+ loading = loading,
+ removedEnabled = true,
+ )
+ }
+ } catch (ex: Exception) {
+ Timber.e(ex, "Error removing %s from removed next up", item.series.id)
+ } finally {
+ _state.update { it.copy(removedEnabled = true) }
+ }
+ }
+ }
+ }
+ }
+ }
+
+@Stable
+data class RemovedItem(
+ val series: BaseItem,
+ val imageUrl: String?,
+ val datetime: LocalDateTime,
+)
+
+data class RemovedNextUpState(
+ val loading: DataLoadingState> = DataLoadingState.Pending,
+ val removedEnabled: Boolean = true,
+)
+
+@Composable
+fun RemovedNextUpContent(
+ modifier: Modifier = Modifier,
+ viewModel: RemovedNextUpContentViewModel = hiltViewModel(),
+) {
+ val state by viewModel.state.collectAsState()
+ Column(
+ modifier = modifier,
+ ) {
+ Text(
+ text = "Removed from next up",
+ style = MaterialTheme.typography.displaySmall,
+ )
+ when (val s = state.loading) {
+ DataLoadingState.Pending,
+ DataLoadingState.Loading,
+ -> {
+ LoadingPage(Modifier.fillMaxWidth())
+ }
+
+ is DataLoadingState.Error -> {
+ ErrorMessage(s, Modifier.fillMaxWidth())
+ }
+
+ is DataLoadingState.Success> -> {
+ if (s.data.isEmpty()) {
+ Text(
+ text = stringResource(R.string.no_results),
+ )
+ } else {
+ val focusRequester = remember { FocusRequester() }
+ LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
+ LazyColumn(
+ contentPadding = PaddingValues(horizontal = 8.dp),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester),
+ ) {
+ items(s.data, key = { it.series.id }) { item ->
+ RemovedListItem(
+ item = item,
+ removedEnabled = state.removedEnabled,
+ onClickRemove =
+ remember {
+ { viewModel.remove(item) }
+ },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun RemovedListItem(
+ item: RemovedItem,
+ removedEnabled: Boolean,
+ onClickRemove: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = modifier,
+ ) {
+ ListItem(
+ selected = false,
+ onClick = {},
+ leadingContent = {
+ AsyncImage(
+ model = item.imageUrl,
+ contentDescription = item.series.title,
+ modifier = Modifier.height(80.dp),
+ )
+ },
+ headlineContent = {
+ Text(
+ text = item.series.title ?: item.series.id.toString(),
+ )
+ },
+ supportingContent = {
+ Text(
+ text = formatDateTime(item.datetime),
+ )
+ },
+ scale = ListItemDefaults.scale(focusedScale = 1f),
+ modifier = Modifier.weight(1f),
+ )
+ Button(
+ onClick = onClickRemove,
+ enabled = removedEnabled,
+ ) {
+ Icon(
+ imageVector = Icons.Default.Delete,
+ contentDescription = "delete",
+ modifier = Modifier,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt
index 5f01f20d..0cf42622 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt
@@ -1,58 +1,28 @@
package com.github.damontecres.wholphin.ui.nav
-import androidx.compose.animation.animateColorAsState
-import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.saveable.rememberSerializable
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.drawBehind
-import androidx.compose.ui.draw.drawWithContent
-import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.graphics.BlendMode
-import androidx.compose.ui.graphics.Brush
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.CompositingStrategy
-import androidx.compose.ui.graphics.SolidColor
-import androidx.compose.ui.graphics.graphicsLayer
-import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.platform.LocalContext
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
-import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavEntry
-import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
-import androidx.navigation3.runtime.serialization.NavBackStackSerializer
-import androidx.navigation3.runtime.serialization.NavKeySerializer
import androidx.navigation3.ui.NavDisplay
import androidx.tv.material3.DrawerValue
-import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.rememberDrawerState
-import coil3.compose.AsyncImage
-import coil3.request.ImageRequest
-import coil3.request.transitionFactory
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
-import com.github.damontecres.wholphin.preferences.BackdropStyle
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager
-import com.github.damontecres.wholphin.ui.CrossFadeFactory
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.launchIO
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
-import kotlin.time.Duration.Companion.milliseconds
// Top scrim configuration for text readability (clock, season tabs)
const val TOP_SCRIM_ALPHA = 0.55f
@@ -78,160 +48,23 @@ class ApplicationContentViewModel
fun ApplicationContent(
server: JellyfinServer,
user: JellyfinUser,
- startDestination: Destination,
navigationManager: NavigationManager,
preferences: UserPreferences,
modifier: Modifier = Modifier,
enableTopScrim: Boolean = true,
viewModel: ApplicationContentViewModel = hiltViewModel(),
) {
- val backStack: MutableList =
- rememberSerializable(
- server,
- user,
- serializer = NavBackStackSerializer(elementSerializer = NavKeySerializer()),
- ) {
- NavBackStack(startDestination)
- }
- navigationManager.backStack = backStack
- val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle()
- val backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle
val drawerState = rememberDrawerState(DrawerValue.Closed)
Box(
modifier = modifier,
) {
- val baseBackgroundColor = MaterialTheme.colorScheme.background
- if (backdrop.hasColors &&
- (backdropStyle == BackdropStyle.BACKDROP_DYNAMIC_COLOR || backdropStyle == BackdropStyle.UNRECOGNIZED)
- ) {
- val animPrimary by animateColorAsState(
- backdrop.primaryColor,
- animationSpec = tween(1250),
- label = "dynamic_backdrop_primary",
- )
- val animSecondary by animateColorAsState(
- backdrop.secondaryColor,
- animationSpec = tween(1250),
- label = "dynamic_backdrop_secondary",
- )
- val animTertiary by animateColorAsState(
- backdrop.tertiaryColor,
- animationSpec = tween(1250),
- label = "dynamic_backdrop_tertiary",
- )
- Box(
- modifier =
- Modifier
- .fillMaxSize()
- .drawBehind {
- drawRect(color = baseBackgroundColor)
- // Top Left (Vibrant/Muted)
- drawRect(
- brush =
- Brush.radialGradient(
- colors = listOf(animSecondary, Color.Transparent),
- center = Offset(0f, 0f),
- radius = size.width * 0.8f,
- ),
- )
- // Bottom Right (DarkVibrant/DarkMuted)
- drawRect(
- brush =
- Brush.radialGradient(
- colors = listOf(animPrimary, Color.Transparent),
- center = Offset(size.width, size.height),
- radius = size.width * 0.8f,
- ),
- )
- // Bottom Left (Dark / Bridge)
- drawRect(
- brush =
- Brush.radialGradient(
- colors =
- listOf(
- baseBackgroundColor,
- Color.Transparent,
- ),
- center = Offset(0f, size.height),
- radius = size.width * 0.8f,
- ),
- )
- // Top Right (Under Image - Vibrant/Bright)
- drawRect(
- brush =
- Brush.radialGradient(
- colors = listOf(animTertiary, Color.Transparent),
- center = Offset(size.width, 0f),
- radius = size.width * 0.8f,
- ),
- )
- },
- )
- }
- if (backdropStyle != BackdropStyle.BACKDROP_NONE) {
- Box(
- modifier = Modifier.fillMaxSize(),
- ) {
- AsyncImage(
- model =
- ImageRequest
- .Builder(LocalContext.current)
- .data(backdrop.imageUrl)
- .transitionFactory(CrossFadeFactory(800.milliseconds))
- .build(),
- contentDescription = null,
- contentScale = ContentScale.Fit,
- alignment = Alignment.TopEnd,
- modifier =
- Modifier
- .align(Alignment.TopEnd)
- .fillMaxHeight(.7f)
- .fillMaxWidth(.7f)
- .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
- .drawWithContent {
- drawContent()
- if (drawerState.isOpen) {
- drawRect(
- brush = SolidColor(Color.Black),
- alpha = .75f,
- )
- }
- // Subtle top scrim for system UI readability (clock, tabs)
- if (enableTopScrim) {
- drawRect(
- brush =
- Brush.verticalGradient(
- colorStops =
- arrayOf(
- 0f to Color.Black.copy(alpha = TOP_SCRIM_ALPHA),
- TOP_SCRIM_END_FRACTION to Color.Transparent,
- ),
- ),
- blendMode = BlendMode.Multiply,
- )
- }
- drawRect(
- brush =
- Brush.horizontalGradient(
- colors = listOf(Color.Transparent, Color.Black),
- startX = 0f,
- endX = size.width * 0.6f,
- ),
- blendMode = BlendMode.DstIn,
- )
- drawRect(
- brush =
- Brush.verticalGradient(
- colors = listOf(Color.Black, Color.Transparent),
- startY = 0f,
- endY = size.height,
- ),
- blendMode = BlendMode.DstIn,
- )
- },
- )
- }
- }
+ val backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle
+ Backdrop(
+ drawerIsOpen = drawerState.isOpen,
+ backdropStyle = backdropStyle,
+ enableTopScrim = enableTopScrim,
+ viewModel = viewModel,
+ )
val navDrawerListState = rememberLazyListState()
NavDisplay(
backStack = navigationManager.backStack,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Backdrop.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Backdrop.kt
new file mode 100644
index 00000000..81028501
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Backdrop.kt
@@ -0,0 +1,211 @@
+@file:OptIn(ExperimentalCoilApi::class)
+
+package com.github.damontecres.wholphin.ui.nav
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.CompositingStrategy
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.tv.material3.MaterialTheme
+import coil3.annotation.ExperimentalCoilApi
+import coil3.compose.AsyncImage
+import coil3.compose.useExistingImageAsPlaceholder
+import coil3.request.ImageRequest
+import coil3.request.transitionFactory
+import com.github.damontecres.wholphin.preferences.BackdropStyle
+import com.github.damontecres.wholphin.services.BackdropResult
+import com.github.damontecres.wholphin.ui.CrossFadeFactory
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+
+/**
+ * Shows the current backdrop images provided by [com.github.damontecres.wholphin.services.BackdropService]
+ */
+@Composable
+fun Backdrop(
+ drawerIsOpen: Boolean,
+ backdropStyle: BackdropStyle,
+ viewModel: ApplicationContentViewModel = hiltViewModel(),
+ modifier: Modifier = Modifier,
+ enableTopScrim: Boolean = true,
+ useExistingImageAsPlaceholder: Boolean = false,
+ crossfadeDuration: Duration = 800.milliseconds,
+) {
+ val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle()
+ Backdrop(
+ backdrop = backdrop,
+ drawerIsOpen = drawerIsOpen,
+ backdropStyle = backdropStyle,
+ modifier = modifier,
+ enableTopScrim = enableTopScrim,
+ useExistingImageAsPlaceholder = useExistingImageAsPlaceholder,
+ crossfadeDuration = crossfadeDuration,
+ )
+}
+
+/**
+ * Shows the current backdrop images provided by the [BackdropResult]
+ */
+@Composable
+fun Backdrop(
+ backdrop: BackdropResult,
+ drawerIsOpen: Boolean,
+ backdropStyle: BackdropStyle,
+ modifier: Modifier = Modifier,
+ enableTopScrim: Boolean = true,
+ useExistingImageAsPlaceholder: Boolean = false,
+ crossfadeDuration: Duration = 800.milliseconds,
+) {
+ val baseBackgroundColor = MaterialTheme.colorScheme.background
+ if (backdrop.hasColors &&
+ (backdropStyle == BackdropStyle.BACKDROP_DYNAMIC_COLOR || backdropStyle == BackdropStyle.UNRECOGNIZED)
+ ) {
+ val animPrimary by animateColorAsState(
+ backdrop.primaryColor,
+ animationSpec = tween(1250),
+ label = "dynamic_backdrop_primary",
+ )
+ val animSecondary by animateColorAsState(
+ backdrop.secondaryColor,
+ animationSpec = tween(1250),
+ label = "dynamic_backdrop_secondary",
+ )
+ val animTertiary by animateColorAsState(
+ backdrop.tertiaryColor,
+ animationSpec = tween(1250),
+ label = "dynamic_backdrop_tertiary",
+ )
+ Box(
+ modifier =
+ modifier
+ .fillMaxSize()
+ .drawBehind {
+ drawRect(color = baseBackgroundColor)
+ // Top Left (Vibrant/Muted)
+ drawRect(
+ brush =
+ Brush.radialGradient(
+ colors = listOf(animSecondary, Color.Transparent),
+ center = Offset(0f, 0f),
+ radius = size.width * 0.8f,
+ ),
+ )
+ // Bottom Right (DarkVibrant/DarkMuted)
+ drawRect(
+ brush =
+ Brush.radialGradient(
+ colors = listOf(animPrimary, Color.Transparent),
+ center = Offset(size.width, size.height),
+ radius = size.width * 0.8f,
+ ),
+ )
+ // Bottom Left (Dark / Bridge)
+ drawRect(
+ brush =
+ Brush.radialGradient(
+ colors =
+ listOf(
+ baseBackgroundColor,
+ Color.Transparent,
+ ),
+ center = Offset(0f, size.height),
+ radius = size.width * 0.8f,
+ ),
+ )
+ // Top Right (Under Image - Vibrant/Bright)
+ drawRect(
+ brush =
+ Brush.radialGradient(
+ colors = listOf(animTertiary, Color.Transparent),
+ center = Offset(size.width, 0f),
+ radius = size.width * 0.8f,
+ ),
+ )
+ },
+ )
+ }
+ if (backdropStyle != BackdropStyle.BACKDROP_NONE) {
+ Box(
+ modifier = modifier.fillMaxSize(),
+ ) {
+ AsyncImage(
+ model =
+ ImageRequest
+ .Builder(LocalContext.current)
+ .data(backdrop.imageUrl)
+ .useExistingImageAsPlaceholder(useExistingImageAsPlaceholder)
+ .transitionFactory(CrossFadeFactory(crossfadeDuration))
+ .build(),
+ contentDescription = null,
+ contentScale = ContentScale.Fit,
+ alignment = Alignment.TopEnd,
+ modifier =
+ Modifier
+ .align(Alignment.TopEnd)
+ .fillMaxHeight(.7f)
+ .fillMaxWidth(.7f)
+ .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
+ .drawWithContent {
+ drawContent()
+ if (drawerIsOpen) {
+ drawRect(
+ brush = SolidColor(Color.Black),
+ alpha = .75f,
+ )
+ }
+ // Subtle top scrim for system UI readability (clock, tabs)
+ if (enableTopScrim) {
+ drawRect(
+ brush =
+ Brush.verticalGradient(
+ colorStops =
+ arrayOf(
+ 0f to Color.Black.copy(alpha = TOP_SCRIM_ALPHA),
+ TOP_SCRIM_END_FRACTION to Color.Transparent,
+ ),
+ ),
+ blendMode = BlendMode.Multiply,
+ )
+ }
+ drawRect(
+ brush =
+ Brush.horizontalGradient(
+ colors = listOf(Color.Transparent, Color.Black),
+ startX = 0f,
+ endX = size.width * 0.6f,
+ ),
+ blendMode = BlendMode.DstIn,
+ )
+ drawRect(
+ brush =
+ Brush.verticalGradient(
+ colors = listOf(Color.Black, Color.Transparent),
+ startY = 0f,
+ endY = size.height,
+ ),
+ blendMode = BlendMode.DstIn,
+ )
+ },
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt
index 59d102c4..a7559914 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt
@@ -12,6 +12,8 @@ import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
+import com.github.damontecres.wholphin.util.DiscoverRequestType
+import com.github.damontecres.wholphin.util.SEERR_PAGE_SIZE
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.BaseItemKind
@@ -97,6 +99,7 @@ sealed class Destination(
@Serializable
data class FilteredCollection(
val itemId: UUID,
+ val parentType: BaseItemKind,
val filter: CollectionFolderFilter,
val recursive: Boolean,
) : Destination(false)
@@ -129,6 +132,14 @@ sealed class Destination(
val item: DiscoverItem,
) : Destination(false)
+ data class DiscoverMoreResult(
+ val type: DiscoverRequestType,
+ val startIndex: Int = SEERR_PAGE_SIZE,
+ ) : Destination(false)
+
+ @Serializable
+ data object NowPlaying : Destination(true)
+
@Serializable
data object UpdateApp : Destination(true)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt
index c0e4ce26..f6f3faa7 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt
@@ -6,15 +6,17 @@ import androidx.compose.ui.Modifier
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.filter.DefaultForGenresFilterOptions
+import com.github.damontecres.wholphin.data.filter.DefaultForStudiosFilterOptions
import com.github.damontecres.wholphin.data.model.SeerrItemType
+import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.ItemGrid
import com.github.damontecres.wholphin.ui.components.LicenseInfo
import com.github.damontecres.wholphin.ui.data.MovieSortOptions
-import com.github.damontecres.wholphin.ui.detail.CollectionFolderBoxSet
import com.github.damontecres.wholphin.ui.detail.CollectionFolderGeneric
import com.github.damontecres.wholphin.ui.detail.CollectionFolderLiveTv
import com.github.damontecres.wholphin.ui.detail.CollectionFolderMovie
+import com.github.damontecres.wholphin.ui.detail.CollectionFolderMusic
import com.github.damontecres.wholphin.ui.detail.CollectionFolderPhotoAlbum
import com.github.damontecres.wholphin.ui.detail.CollectionFolderPlaylist
import com.github.damontecres.wholphin.ui.detail.CollectionFolderRecordings
@@ -23,17 +25,23 @@ import com.github.damontecres.wholphin.ui.detail.DebugPage
import com.github.damontecres.wholphin.ui.detail.FavoritesPage
import com.github.damontecres.wholphin.ui.detail.PersonPage
import com.github.damontecres.wholphin.ui.detail.PlaylistDetails
+import com.github.damontecres.wholphin.ui.detail.collection.CollectionDetails
import com.github.damontecres.wholphin.ui.detail.discover.DiscoverMovieDetails
import com.github.damontecres.wholphin.ui.detail.discover.DiscoverPersonPage
import com.github.damontecres.wholphin.ui.detail.discover.DiscoverSeriesDetails
import com.github.damontecres.wholphin.ui.detail.episode.EpisodeDetails
import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails
+import com.github.damontecres.wholphin.ui.detail.music.AlbumDetailsPage
+import com.github.damontecres.wholphin.ui.detail.music.ArtistDetailsPage
+import com.github.damontecres.wholphin.ui.detail.music.NowPlayingPage
import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails
import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview
import com.github.damontecres.wholphin.ui.discover.DiscoverPage
+import com.github.damontecres.wholphin.ui.discover.DiscoverRequestGrid
import com.github.damontecres.wholphin.ui.main.HomePage
import com.github.damontecres.wholphin.ui.main.SearchPage
import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsPage
+import com.github.damontecres.wholphin.ui.playback.PlayExternalPage
import com.github.damontecres.wholphin.ui.playback.PlaybackPage
import com.github.damontecres.wholphin.ui.preferences.PreferencesPage
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage
@@ -65,17 +73,25 @@ fun DestinationContent(
}
is Destination.HomeSettings -> {
- HomeSettingsPage(modifier)
+ HomeSettingsPage(preferences, modifier)
}
is Destination.PlaybackList,
is Destination.Playback,
-> {
- PlaybackPage(
- preferences = preferences,
- destination = destination,
- modifier = modifier,
- )
+ if (preferences.appPreferences.playbackPreferences.playerBackend == PlayerBackend.EXTERNAL_PLAYER) {
+ PlayExternalPage(
+ preferences = preferences,
+ destination = destination,
+ modifier = modifier,
+ )
+ } else {
+ PlaybackPage(
+ preferences = preferences,
+ destination = destination,
+ modifier = modifier,
+ )
+ }
}
is Destination.Settings -> {
@@ -142,11 +158,9 @@ fun DestinationContent(
BaseItemKind.BOX_SET -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
- CollectionFolderBoxSet(
+ CollectionDetails(
preferences = preferences,
itemId = destination.itemId,
- recursive = false,
- playEnabled = true,
modifier = modifier,
)
}
@@ -154,6 +168,7 @@ fun DestinationContent(
BaseItemKind.PLAYLIST -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
PlaylistDetails(
+ preferences = preferences,
destination = destination,
modifier = modifier,
)
@@ -214,6 +229,24 @@ fun DestinationContent(
)
}
+ BaseItemKind.MUSIC_ALBUM -> {
+ LaunchedEffect(Unit) { onClearBackdrop.invoke() }
+ AlbumDetailsPage(
+ preferences = preferences,
+ itemId = destination.itemId,
+ modifier = modifier,
+ )
+ }
+
+ BaseItemKind.MUSIC_ARTIST -> {
+ LaunchedEffect(Unit) { onClearBackdrop.invoke() }
+ ArtistDetailsPage(
+ preferences = preferences,
+ itemId = destination.itemId,
+ modifier = modifier,
+ )
+ }
+
else -> {
Timber.w("Unsupported item type: ${destination.type}")
Text("Unsupported item type: ${destination.type}", modifier)
@@ -230,7 +263,12 @@ fun DestinationContent(
recursive = destination.recursive,
usePosters = true,
playEnabled = true, // TODO only genres use this currently, so might need to change in future
- filterOptions = DefaultForGenresFilterOptions,
+ filterOptions =
+ when (destination.parentType) {
+ BaseItemKind.GENRE -> DefaultForGenresFilterOptions
+ BaseItemKind.STUDIO -> DefaultForStudiosFilterOptions
+ else -> throw IllegalArgumentException("Unsupported parentType ${destination.parentType}")
+ },
modifier = modifier,
)
}
@@ -267,6 +305,10 @@ fun DestinationContent(
)
}
+ Destination.NowPlaying -> {
+ NowPlayingPage(modifier)
+ }
+
Destination.UpdateApp -> {
InstallUpdatePage(preferences, modifier)
}
@@ -328,6 +370,14 @@ fun DestinationContent(
}
}
}
+
+ is Destination.DiscoverMoreResult -> {
+ LaunchedEffect(Unit) { onClearBackdrop.invoke() }
+ DiscoverRequestGrid(
+ destination = destination,
+ modifier = modifier,
+ )
+ }
}
}
@@ -386,6 +436,14 @@ fun CollectionFolder(
)
}
+ CollectionType.MUSIC -> {
+ CollectionFolderMusic(
+ preferences,
+ destination,
+ modifier,
+ )
+ }
+
CollectionType.HOMEVIDEOS,
CollectionType.PHOTOS,
-> {
@@ -398,7 +456,6 @@ fun CollectionFolder(
}
CollectionType.MUSICVIDEOS,
- CollectionType.MUSIC,
CollectionType.BOOKS,
-> {
CollectionFolderGeneric(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt
index f0276ffd..26068182 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt
@@ -2,9 +2,12 @@ package com.github.damontecres.wholphin.ui.nav
import android.content.Context
import androidx.activity.compose.BackHandler
+import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.animateIntOffsetAsState
import androidx.compose.animation.core.spring
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
@@ -24,6 +27,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.KeyboardArrowLeft
+import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.Composable
@@ -72,6 +76,7 @@ import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.AppThemeColors
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
+import com.github.damontecres.wholphin.services.MusicService
import com.github.damontecres.wholphin.services.NavDrawerService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SetupDestination
@@ -104,6 +109,7 @@ class NavDrawerViewModel
val navigationManager: NavigationManager,
val setupNavigationManager: SetupNavigationManager,
val backdropService: BackdropService,
+ private val musicService: MusicService,
) : ViewModel() {
val state = navDrawerService.state
@@ -151,6 +157,9 @@ class NavDrawerViewModel
fun getUserImage(user: JellyfinUser): String = api.imageApi.getUserImageUrl(user.id)
+ /**
+ * Determine which nav drawer item should be highlighted as currently selected
+ */
fun updateSelectedIndex() {
viewModelScope.launchDefault {
val asDestinations =
@@ -178,9 +187,11 @@ class NavDrawerViewModel
if (key is Destination) {
val index =
if (key is Destination.Home) {
- -1
+ HOME_INDEX
} else if (key is Destination.Search) {
- -2
+ SEARCH_INDEX
+ } else if (key is Destination.NowPlaying) {
+ NOW_PLAYING_INDEX
} else {
val idx = asDestinations.indexOf(key)
if (idx >= 0) {
@@ -198,8 +209,20 @@ class NavDrawerViewModel
}
}
}
+
+ fun navigateToSetup(userList: SetupDestination) {
+ viewModelScope.launchDefault {
+ musicService.stop()
+ setupNavigationManager.navigateTo(userList)
+ }
+ }
}
+/**
+ * An item that can be shown in the nav drawer
+ *
+ * Some are built-in such as Favorites. Others are created dynamically for libraries.
+ */
sealed interface NavDrawerItem {
val id: String
@@ -227,6 +250,9 @@ sealed interface NavDrawerItem {
}
}
+/**
+ * A server provided nav drawer item, typically a library
+ */
data class ServerNavDrawerItem(
val itemId: UUID,
val name: String,
@@ -242,6 +268,10 @@ data class ServerNavDrawerItem(
}
}
+private const val HOME_INDEX = -1
+private const val SEARCH_INDEX = -2
+private const val NOW_PLAYING_INDEX = -3
+
/**
* Display the left side navigation drawer with [DestinationContent] on the right
*/
@@ -324,12 +354,37 @@ fun NavDrawer(
drawerOpen = isOpen,
interactionSource = interactionSource,
onClick = {
- viewModel.setupNavigationManager.navigateTo(
+ viewModel.navigateToSetup(
SetupDestination.UserList(server),
)
},
modifier = Modifier,
)
+ AnimatedVisibility(
+ visible = state.nowPlayingEnabled,
+ enter = expandVertically(expandFrom = Alignment.Top),
+ exit = shrinkVertically(shrinkTowards = Alignment.Top),
+ ) {
+ val interactionSource = remember { MutableInteractionSource() }
+ IconNavItem(
+ text = stringResource(R.string.now_playing),
+ subtext = state.nowPlayingTitle,
+ icon = Icons.Default.PlayArrow,
+ selected = selectedIndex == NOW_PLAYING_INDEX,
+ drawerOpen = isOpen,
+ interactionSource = interactionSource,
+ onClick = {
+ viewModel.setIndex(NOW_PLAYING_INDEX)
+ viewModel.navigationManager.navigateTo(Destination.NowPlaying)
+ },
+ modifier =
+ Modifier
+ .ifElse(
+ selectedIndex == NOW_PLAYING_INDEX,
+ Modifier.focusRequester(focusRequester),
+ ),
+ )
+ }
LazyColumn(
state = navDrawerListState,
contentPadding = PaddingValues(0.dp),
@@ -353,18 +408,18 @@ fun NavDrawer(
IconNavItem(
text = stringResource(R.string.search),
icon = Icons.Default.Search,
- selected = selectedIndex == -2,
+ selected = selectedIndex == SEARCH_INDEX,
drawerOpen = isOpen,
interactionSource = interactionSource,
onClick = {
- viewModel.setIndex(-2)
+ viewModel.setIndex(SEARCH_INDEX)
viewModel.navigationManager.navigateToFromDrawer(Destination.Search)
},
modifier =
Modifier
.focusRequester(searchFocusRequester)
.ifElse(
- selectedIndex == -2,
+ selectedIndex == SEARCH_INDEX,
Modifier.focusRequester(focusRequester),
),
)
@@ -374,11 +429,11 @@ fun NavDrawer(
IconNavItem(
text = stringResource(R.string.home),
icon = Icons.Default.Home,
- selected = selectedIndex == -1,
+ selected = selectedIndex == HOME_INDEX,
drawerOpen = isOpen,
interactionSource = interactionSource,
onClick = {
- viewModel.setIndex(-1)
+ viewModel.setIndex(HOME_INDEX)
if (destination is Destination.Home) {
viewModel.navigationManager.reloadHome()
} else {
@@ -388,7 +443,7 @@ fun NavDrawer(
modifier =
Modifier
.ifElse(
- selectedIndex == -1,
+ selectedIndex == HOME_INDEX,
Modifier.focusRequester(focusRequester),
),
)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavigationDrawerAndroid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavigationDrawerAndroid.kt
index 30b9d1d6..0cca40c8 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavigationDrawerAndroid.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavigationDrawerAndroid.kt
@@ -57,6 +57,11 @@ import androidx.tv.material3.NavigationDrawerItemDefaults
import androidx.tv.material3.NavigationDrawerScope
import androidx.tv.material3.rememberDrawerState
+/**
+ * This is a re-implementation of [androidx.tv.material3.ModalNavigationDrawer].
+ *
+ * It changes padding, sizing, and animations that couldn't be changed in the Androidx implementation.
+ */
@Composable
fun ModalNavigationDrawer(
drawerContent: @Composable NavigationDrawerScope.(DrawerValue) -> Unit,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt
index e8dc7d1d..40b5e89b 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt
@@ -8,6 +8,11 @@ import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.TrickplayInfo
import org.jellyfin.sdk.model.extensions.ticks
+/**
+ * Metadata about the currently playing media
+ *
+ * @see CurrentPlayback
+ */
data class CurrentMediaInfo(
val sourceId: String?,
val videoStream: SimpleVideoStream?,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentPlayback.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentPlayback.kt
index 9c62f17b..b0f51e56 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentPlayback.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentPlayback.kt
@@ -8,6 +8,11 @@ import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.TranscodingInfo
import kotlin.time.Duration
+/**
+ * Information about how the current media is being played such transcoding and decoder info
+ *
+ * @see CurrentMediaInfo
+ */
data class CurrentPlayback(
val item: BaseItem,
val tracks: List,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt
index e1a291e1..d4039f72 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt
@@ -51,7 +51,7 @@ import org.jellyfin.sdk.model.api.RemoteSubtitleInfo
@Composable
fun DownloadSubtitlesContent(
- state: SubtitleSearch,
+ state: SubtitleSearchStatus,
language: String,
onSearch: (String) -> Unit,
onClickDownload: (RemoteSubtitleInfo) -> Unit,
@@ -59,7 +59,7 @@ fun DownloadSubtitlesContent(
modifier: Modifier = Modifier,
) {
when (val s = state) {
- SubtitleSearch.Searching -> {
+ SubtitleSearchStatus.Searching -> {
Wrapper {
Text(
text = stringResource(R.string.searching),
@@ -69,7 +69,7 @@ fun DownloadSubtitlesContent(
}
}
- SubtitleSearch.Downloading -> {
+ SubtitleSearchStatus.Downloading -> {
Wrapper {
Text(
text = stringResource(R.string.downloading),
@@ -79,11 +79,11 @@ fun DownloadSubtitlesContent(
}
}
- is SubtitleSearch.Error -> {
+ is SubtitleSearchStatus.Error -> {
Wrapper { ErrorMessage(null, s.ex, modifier) }
}
- is SubtitleSearch.Success -> {
+ is SubtitleSearchStatus.Success -> {
val dialogItems = convertRemoteSubtitles(s.options, onClickDownload)
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/MediaSessionPlayer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/MediaSessionPlayer.kt
index 94a41aa1..30165f58 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/MediaSessionPlayer.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/MediaSessionPlayer.kt
@@ -1,23 +1,23 @@
package com.github.damontecres.wholphin.ui.playback
+import androidx.annotation.OptIn
import androidx.media3.common.ForwardingSimpleBasePlayer
import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
import com.github.damontecres.wholphin.preferences.PlaybackPreferences
import com.github.damontecres.wholphin.preferences.skipBackOnResume
import com.github.damontecres.wholphin.ui.seekBack
import com.google.common.util.concurrent.ListenableFuture
import timber.log.Timber
+@OptIn(UnstableApi::class)
class MediaSessionPlayer(
player: Player,
- private val controllerViewState: ControllerViewState,
private val playbackPreferences: PlaybackPreferences,
) : ForwardingSimpleBasePlayer(player) {
override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> {
Timber.v("handleSetPlayWhenReady: playWhenReady=$playWhenReady")
- if (!playWhenReady && player.isPlaying) {
- controllerViewState.showControls()
- } else if (playWhenReady) {
+ if (playWhenReady) {
playbackPreferences.skipBackOnResume?.let {
player.seekBack(it)
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PauseIndicator.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PauseIndicator.kt
new file mode 100644
index 00000000..2f812970
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PauseIndicator.kt
@@ -0,0 +1,101 @@
+package com.github.damontecres.wholphin.ui.playback
+
+import androidx.annotation.OptIn
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.ui.compose.state.observeState
+import androidx.tv.material3.Icon
+import androidx.tv.material3.MaterialTheme
+import com.github.damontecres.wholphin.R
+import kotlinx.coroutines.delay
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+
+/**
+ * Show an animated "pause" image whenever the player is paused
+ */
+@Composable
+fun PauseIndicator(
+ player: Player,
+ modifier: Modifier = Modifier,
+ duration: Duration = 300.milliseconds,
+) {
+ val state = rememberPauseState(player)
+ var visible by remember { mutableStateOf(false) }
+ LaunchedEffect(state.isPaused) {
+ if (state.isPaused) visible = true
+ }
+ AnimatedVisibility(
+ visible = visible,
+ enter =
+ scaleIn(
+ animationSpec =
+ tween(
+ durationMillis = duration.inWholeMilliseconds.toInt(),
+ ),
+ ),
+ exit = fadeOut(spring(stiffness = Spring.StiffnessMediumLow)),
+ modifier = modifier,
+ ) {
+ LaunchedEffect(Unit) {
+ delay(duration)
+ delay(50)
+ visible = false
+ }
+ Icon(
+ modifier = Modifier.size(64.dp, 64.dp),
+ painter = painterResource(id = R.drawable.baseline_pause_24),
+ tint = MaterialTheme.colorScheme.onSurface,
+ contentDescription = null,
+ )
+ }
+}
+
+/**
+ * Remember when a player is paused
+ */
+@Composable
+fun rememberPauseState(player: Player): PauseState {
+ val state = remember(player) { PauseState(player) }
+ LaunchedEffect(player) { state.observe() }
+ return state
+}
+
+class PauseState(
+ private val player: Player,
+) {
+ var isPaused by mutableStateOf(false)
+ private set
+
+ @OptIn(UnstableApi::class)
+ internal suspend fun observe() {
+ player
+ .observeState(
+ Player.EVENT_PLAYBACK_STATE_CHANGED,
+ Player.EVENT_PLAY_WHEN_READY_CHANGED,
+ Player.EVENT_AVAILABLE_COMMANDS_CHANGED,
+ ) {
+ // Timber.v("isPaused=$isPaused, playWhenReady=${player.playWhenReady}, playbackState=${player.playbackState}")
+ isPaused = !isPaused && // Not already paused, don't want to trigger more than once
+ !player.playWhenReady && // Player is actually paused
+ // Player could play if it was not paused, ie it is not stopped
+ player.playbackState.let { it == Player.STATE_READY || it == Player.STATE_BUFFERING }
+ }.observe()
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt
new file mode 100644
index 00000000..c14d184d
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt
@@ -0,0 +1,417 @@
+package com.github.damontecres.wholphin.ui.playback
+
+import android.app.Activity
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.widget.Toast
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.ActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.core.net.toUri
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.compose.LifecycleStartEffect
+import androidx.lifecycle.viewModelScope
+import com.github.damontecres.wholphin.data.ItemPlaybackDao
+import com.github.damontecres.wholphin.data.ServerRepository
+import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.preferences.UserPreferences
+import com.github.damontecres.wholphin.services.NavigationManager
+import com.github.damontecres.wholphin.services.PlaylistCreationResult
+import com.github.damontecres.wholphin.services.PlaylistCreator
+import com.github.damontecres.wholphin.services.StreamChoiceService
+import com.github.damontecres.wholphin.services.UserPreferencesService
+import com.github.damontecres.wholphin.ui.components.ErrorMessage
+import com.github.damontecres.wholphin.ui.components.LoadingPage
+import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
+import com.github.damontecres.wholphin.ui.isNotNullOrBlank
+import com.github.damontecres.wholphin.ui.launchDefault
+import com.github.damontecres.wholphin.ui.nav.Destination
+import com.github.damontecres.wholphin.ui.preferences.getExternalPlayers
+import com.github.damontecres.wholphin.ui.showToast
+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.CancellationException
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.update
+import org.jellyfin.sdk.api.client.ApiClient
+import org.jellyfin.sdk.api.client.extensions.playStateApi
+import org.jellyfin.sdk.api.client.extensions.subtitleApi
+import org.jellyfin.sdk.api.client.extensions.userLibraryApi
+import org.jellyfin.sdk.api.client.extensions.videosApi
+import org.jellyfin.sdk.model.api.MediaStream
+import org.jellyfin.sdk.model.api.PlaybackStopInfo
+import org.jellyfin.sdk.model.extensions.inWholeTicks
+import org.jellyfin.sdk.model.extensions.ticks
+import timber.log.Timber
+import java.io.File
+import java.util.UUID
+import kotlin.time.Duration.Companion.milliseconds
+
+@HiltViewModel(assistedFactory = PlayExternalViewModel.Factory::class)
+class PlayExternalViewModel
+ @AssistedInject
+ constructor(
+ private val savedStateHandle: SavedStateHandle,
+ @param:ApplicationContext private val context: Context,
+ private val api: ApiClient,
+ private val serverRepository: ServerRepository,
+ private val itemPlaybackDao: ItemPlaybackDao,
+ private val playlistCreator: PlaylistCreator,
+ private val streamChoiceService: StreamChoiceService,
+ private val navigationManager: NavigationManager,
+ private val userPreferencesService: UserPreferencesService,
+ @Assisted val destination: Destination,
+ ) : ViewModel() {
+ @AssistedFactory
+ interface Factory {
+ fun create(destination: Destination): PlayExternalViewModel
+ }
+
+ val state = MutableStateFlow(PlayExternalState())
+
+ fun init() {
+ viewModelScope.launchDefault {
+ val prefs = userPreferencesService.getCurrent()
+ val positionMs: Long
+ val itemId =
+ when (val d = destination) {
+ is Destination.Playback -> {
+ positionMs = d.positionMs
+ d.itemId
+ }
+
+ is Destination.PlaybackList -> {
+ positionMs = 0
+ d.itemId
+ }
+
+ else -> {
+ throw IllegalArgumentException("Destination not supported: $destination")
+ }
+ }
+ try {
+ val queriedItem = api.userLibraryApi.getItem(itemId).content
+ val base =
+ if (queriedItem.type.playable) {
+ queriedItem
+ } else if (destination is Destination.PlaybackList) {
+ val playlistResult =
+ playlistCreator.createFrom(
+ item = queriedItem,
+ startIndex = destination.startIndex ?: 0,
+ sortAndDirection = destination.sortAndDirection,
+ shuffled = destination.shuffle,
+ recursive = destination.recursive,
+ filter = destination.filter,
+ )
+ when (val r = playlistResult) {
+ is PlaylistCreationResult.Error -> {
+ state.update {
+ it.copy(
+ loading = LoadingState.Error(r.message, r.ex),
+ )
+ }
+ return@launchDefault
+ }
+
+ is PlaylistCreationResult.Success -> {
+ if (r.playlist.items.isEmpty()) {
+ showToast(context, "Playlist is empty", Toast.LENGTH_SHORT)
+ navigationManager.goBack()
+ return@launchDefault
+ }
+ r.playlist.items
+ .first()
+ .data
+ }
+ }
+ } else {
+ throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}")
+ }
+ val item = BaseItem(base, false)
+ val playbackConfig =
+ serverRepository.currentUser.value?.let { user ->
+ itemPlaybackDao.getItem(user, base.id)?.let {
+ Timber.v("Fetched itemPlayback from DB: %s", it)
+ if (it.sourceId != null) {
+ it
+ } else {
+ null
+ }
+ }
+ }
+ val mediaSource = streamChoiceService.chooseSource(base, playbackConfig)
+ val plc = streamChoiceService.getPlaybackLanguageChoice(base)
+ if (mediaSource == null) {
+ Timber.w("Media source is null")
+ return@launchDefault
+ }
+ savedStateHandle[KEY_ID] = base.id
+ savedStateHandle[KEY_MEDIA_ID] = mediaSource.id
+ val subtitleIndex =
+ streamChoiceService
+ .chooseSubtitleStream(
+ source = mediaSource,
+ audioStream = null,
+ seriesId = base.seriesId,
+ itemPlayback = playbackConfig,
+ plc = plc,
+ prefs = prefs,
+ )?.index
+ val externalSubtitles =
+ mediaSource.mediaStreams
+ ?.filter { it.isExternal }
+ ?.sortedWith(compareBy { it.index == subtitleIndex }.thenBy { it.isDefault })
+ .orEmpty()
+ val subtitleUrls =
+ externalSubtitles.map {
+ val format = it.path?.let { File(it).extension } ?: "srt"
+ api.subtitleApi
+ .getSubtitleUrl(
+ routeItemId = itemId,
+ routeMediaSourceId = mediaSource.id!!,
+ routeIndex = it.index,
+ routeFormat = format,
+ ).toUri()
+ }
+
+ val uri =
+ api.videosApi
+ .getVideoStreamUrl(
+ itemId = item.id,
+ mediaSourceId = mediaSource.id,
+ static = true,
+ ).toUri()
+ val playerId = prefs.appPreferences.playbackPreferences.externalPlayer
+ // Make sure player is available, user could have uninstalled it
+ val foundPlayer =
+ getExternalPlayers(context).firstOrNull { it.identifier == playerId } != null
+ val component =
+ if (playerId.isNotNullOrBlank() && foundPlayer) {
+ ComponentName.unflattenFromString(playerId)
+ } else {
+ null
+ }
+ Timber.v("playerId=%s, component=%s", playerId, component)
+ val title = "${item.title} ${item.subtitleLong}"
+ val intent =
+ Intent(Intent.ACTION_VIEW).apply {
+ setComponent(component)
+ setDataAndTypeAndNormalize(uri, "video/*")
+ putExtra("title", title)
+ putExtra("position", positionMs.toInt())
+
+ // MX/mpv
+ putExtra("return_result", true)
+ putExtra("secure_uri", true)
+ putExtra("subs", subtitleUrls.toTypedArray())
+ putExtra(
+ "subs.name",
+ externalSubtitles
+ .map { it.displayTitle ?: it.index.toString() }
+ .toTypedArray(),
+ )
+ if (subtitleIndex != null) {
+ externalSubtitles
+ .indexOfFirstOrNull { it.index == subtitleIndex }
+ ?.let {
+ putExtra("subs.enable", arrayOf(subtitleUrls[it]))
+ }
+ }
+
+ // VLC
+ if (subtitleUrls.isNotEmpty()) {
+ putExtra("subtitles_location", subtitleUrls.first().toString())
+ }
+ mediaSource.runTimeTicks?.ticks?.inWholeMilliseconds?.let {
+ putExtra("extra_duration", it)
+ }
+
+ // Vimu - https://vimu.tv/player-api/
+ putExtra("startfrom", positionMs.toInt())
+ putExtra("forceresume", false)
+ putExtra("forcename", title)
+ externalSubtitles
+ .indexOfFirstOrNull { it.index == subtitleIndex && it.codec == "srt" }
+ ?.let {
+ putExtra("forcedsrt", subtitleUrls[it])
+ }
+ }
+
+ state.update {
+ PlayExternalState(
+ loading = LoadingState.Success,
+ intent = intent,
+ )
+ }
+ } catch (ex: Exception) {
+ Timber.e(ex, "Error for destination %s", destination)
+ state.update {
+ it.copy(loading = LoadingState.Error(ex))
+ }
+ }
+ }
+ }
+
+ fun onResult(result: ActivityResult) {
+ viewModelScope.launchDefault {
+ val itemId = savedStateHandle.get(KEY_ID)
+ try {
+ val mediaSourceId = savedStateHandle.get(KEY_MEDIA_ID)
+ if (itemId == null) {
+ Timber.w("itemId is null")
+ return@launchDefault
+ }
+ Timber.v(
+ "Result: result=%s, itemId=%s action=%s",
+ result.resultCode,
+ itemId,
+ result.data?.action,
+ )
+ if (result.resultCode == Activity.RESULT_OK || result.resultCode == Activity.RESULT_CANCELED ||
+ // Vimu return 1 for video completion
+ (result.data?.action == "net.gtvbox.videoplayer.result" && result.resultCode == 1)
+ ) {
+ val position: Long?
+ val data = result.data
+ when (data?.action) {
+ // VLC: https://wiki.videolan.org/Android_Player_Intents/
+ "org.videolan.vlc.player.result" -> {
+ position =
+ data
+ .getLongExtra("extra_position", Long.MIN_VALUE)
+ .takeIf { it >= 0 }
+ }
+
+ // mpv-android: https://mpv-android.github.io/mpv-android/intent.html
+ "is.xyz.mpv.MPVActivity.result",
+ // MX player: https://mx.j2inter.com/api
+ "com.mxtech.intent.result.VIEW",
+ // VIMU: https://vimu.tv/player-api/
+ "net.gtvbox.videoplayer.result",
+ -> {
+ position =
+ data
+ .getIntExtra("position", Int.MIN_VALUE)
+ .toLong()
+ .takeIf { it >= 0 }
+ }
+
+ else -> {
+ // Unsupported app
+ val posInt =
+ data
+ ?.getIntExtra("position", Int.MIN_VALUE)
+ ?.takeIf { it >= 0 }
+ ?.toLong()
+ position =
+ posInt ?: data
+ ?.getLongExtra("position", -1L)
+ ?.takeIf { it >= 0 }
+ }
+ }
+ Timber.v("Result position: %s", position?.milliseconds)
+ api.playStateApi.reportPlaybackStopped(
+ PlaybackStopInfo(
+ itemId = itemId,
+ mediaSourceId = mediaSourceId,
+ positionTicks = position?.milliseconds?.inWholeTicks,
+ failed = false,
+ ),
+ )
+ } else {
+ Timber.w("Activity result: %s", result.resultCode)
+ showToast(context, "Unknown result from external player")
+ }
+ navigationManager.goBack()
+ } catch (_: CancellationException) {
+ } catch (ex: Exception) {
+ Timber.e(ex, "Error during external playback of %s", itemId)
+ state.update { it.copy(loading = LoadingState.Error(ex)) }
+ }
+ }
+ }
+
+ fun reportException(ex: Exception) {
+ Timber.e(ex, "Error launching activity")
+ state.update { it.copy(loading = LoadingState.Error(ex)) }
+ }
+
+ companion object {
+ private const val KEY_ID = "itemId"
+ private const val KEY_MEDIA_ID = "mediaId"
+ }
+ }
+
+data class PlayExternalState(
+ val loading: LoadingState = LoadingState.Loading,
+ val intent: Intent = Intent(),
+)
+
+@Composable
+fun PlayExternalPage(
+ preferences: UserPreferences,
+ destination: Destination,
+ modifier: Modifier = Modifier,
+ viewModel: PlayExternalViewModel =
+ hiltViewModel