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/workflows/main.yml b/.github/workflows/main.yml
index 9583525d..6f130105 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,7 +67,7 @@ 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
@@ -82,6 +81,8 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
+ shopt -s globstar
+
gh release delete "${{ env.TAG_NAME }}" --cleanup-tag -y || true
@@ -98,4 +99,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..5a7de9cb 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 --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,16 @@ 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"
+
+ aab=$(find app/build/outputs -name '*.aab')
+ echo "aab=$aab" >> "$GITHUB_OUTPUT"
- name: Checksums
run: |
echo "SHA256 checksums:"
@@ -84,6 +73,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 390b2d63..21d41e3e 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -43,21 +43,11 @@ 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).
-
-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.
+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.
### App settings
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 799d0f14..d5cfeeac 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -21,6 +21,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 +47,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 +110,20 @@ android {
}
}
}
+ flavorDimensions += "version"
+ productFlavors {
+ create("default") {
+ dimension = "version"
+ isDefault = true
+ manifestPlaceholders += mapOf("leanback" to false)
+ buildConfigField("boolean", "UPDATING_ENABLED", "true")
+ }
+ create("appstore") {
+ dimension = "version"
+ manifestPlaceholders += mapOf("leanback" to true)
+ buildConfigField("boolean", "UPDATING_ENABLED", "false")
+ }
+ }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
@@ -289,11 +307,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/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..8dd32ce9 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -20,7 +20,7 @@
android:required="false" />
+ android:required="${leanback}" />
diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt
index e8791bde..5984a046 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt
@@ -1,5 +1,6 @@
package com.github.damontecres.wholphin
+import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.os.Bundle
@@ -34,6 +35,7 @@ import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.DatePlayedInvalidationService
import com.github.damontecres.wholphin.services.DeviceProfileService
import com.github.damontecres.wholphin.services.ImageUrlService
+import com.github.damontecres.wholphin.services.LatestNextUpSchedulerService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver
import com.github.damontecres.wholphin.services.RefreshRateService
@@ -51,14 +53,15 @@ import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.launchDefault
-import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
+import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
import com.github.damontecres.wholphin.util.DebugLogTree
import com.github.damontecres.wholphin.util.ExceptionHandler
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch
@@ -113,6 +116,9 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var suggestionsSchedulerService: SuggestionsSchedulerService
+ @Inject
+ lateinit var latestNextUpSchedulerService: LatestNextUpSchedulerService
+
@Inject
lateinit var backdropService: BackdropService
@@ -394,6 +400,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,
@@ -402,9 +409,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()
@@ -447,7 +464,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/data/filter/ItemFilterBy.kt b/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt
index 1d536f28..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,
@@ -183,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/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt
index cf7461ea..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
@@ -79,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
@@ -92,8 +91,7 @@ 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
@@ -270,6 +268,7 @@ fun createGenreDestination(
includeItemTypes: List?,
) = Destination.FilteredCollection(
itemId = parentId,
+ parentType = BaseItemKind.GENRE,
filter =
CollectionFolderFilter(
nameOverride =
@@ -286,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/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/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt
index 1c35a270..a828b27e 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
@@ -43,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)
@@ -60,31 +58,43 @@ class AppUpgradeHandler
} else {
pkgInfo.versionCode.toLong()
}
- if (newVersion != previousVersion || newVersionCode != previousVersionCode) {
- Timber.i(
- "App updated: $previousVersion=>$newVersion, $previousVersionCode=>$newVersionCode",
- )
- // 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)
+ 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()
}
- 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")
+ // 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")
}
/**
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/FavoriteWatchManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/FavoriteWatchManager.kt
index cdaa0db4..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
@@ -39,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 2abe6a1e..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,6 +59,7 @@ 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
@@ -78,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 =
@@ -100,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))
+ }
}
/**
@@ -124,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]
@@ -331,12 +317,12 @@ class HomeSettingsService
*/
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 =
@@ -483,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)
}
@@ -592,6 +587,7 @@ class HomeSettingsService
title = context.getString(R.string.continue_watching),
items = resume,
viewOptions = row.viewOptions,
+ rowType = row,
)
}
@@ -610,6 +606,7 @@ class HomeSettingsService
title = context.getString(R.string.next_up),
items = nextUp,
viewOptions = row.viewOptions,
+ rowType = row,
)
}
@@ -639,6 +636,7 @@ class HomeSettingsService
nextUp,
),
viewOptions = row.viewOptions,
+ rowType = row,
)
}
@@ -698,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,
)
}
@@ -725,6 +775,7 @@ class HomeSettingsService
title,
it,
row.viewOptions,
+ rowType = row,
)
}
latest
@@ -757,6 +808,7 @@ class HomeSettingsService
title,
it,
row.viewOptions,
+ rowType = row,
)
}
}
@@ -785,6 +837,7 @@ class HomeSettingsService
name ?: context.getString(R.string.collection),
it,
row.viewOptions,
+ rowType = row,
)
}
}
@@ -812,6 +865,7 @@ class HomeSettingsService
row.name,
it,
row.viewOptions,
+ rowType = row,
)
}
}
@@ -862,6 +916,7 @@ class HomeSettingsService
title,
it,
row.viewOptions,
+ rowType = row,
)
}
}
@@ -886,6 +941,7 @@ class HomeSettingsService
context.getString(R.string.active_recordings),
it,
row.viewOptions,
+ rowType = row,
)
}
}
@@ -910,6 +966,7 @@ class HomeSettingsService
context.getString(R.string.live_tv),
it,
row.viewOptions,
+ rowType = row,
)
}
}
@@ -927,6 +984,7 @@ class HomeSettingsService
context.getString(R.string.channels),
it,
row.viewOptions,
+ rowType = row,
)
}
}
@@ -949,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(
@@ -966,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/LatestNextUpService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt
index ce945b69..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,22 +1,32 @@
+@file:UseSerializers(
+ UUIDSerializer::class,
+ LocalDateTimeSerializer::class,
+)
+
package com.github.damontecres.wholphin.services
-import android.content.Context
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.SlimItemFields
+import com.github.damontecres.wholphin.util.LocalDateTimeSerializer
import com.github.damontecres.wholphin.util.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.model.api.BaseItemKind
+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
@@ -31,9 +41,10 @@ import kotlin.time.Duration.Companion.milliseconds
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
@@ -80,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 =
@@ -100,6 +112,23 @@ 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
}
@@ -140,4 +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"
+ }
}
+
+@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/MusicService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt
index 002be20e..0fb0c774 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt
@@ -30,7 +30,7 @@ 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.Codec
+import com.github.damontecres.wholphin.util.profile.supportedAudioCodecs
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -83,6 +83,8 @@ class MusicService
private val _state = MutableStateFlow(MusicServiceState.EMPTY)
val state: StateFlow = _state
+ private val audioFormats by lazy { listOf(*supportedAudioCodecs) }
+
val player: Player by lazy {
ExoPlayer
.Builder(context)
@@ -286,14 +288,9 @@ class MusicService
val url =
api.universalAudioApi.getUniversalAudioStreamUrl(
itemId = audio.id,
- container =
- listOf(
- Codec.Audio.OPUS,
- Codec.Audio.MP3,
- Codec.Audio.AAC,
- Codec.Audio.FLAC,
- ),
+ container = audioFormats,
)
+ Timber.i("url=%s", url)
val imageUrl =
audio.data.albumId?.let { albumId ->
imageUrlService.getItemImageUrl(
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/UpdateChecker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt
index 9e0665dd..40a04218 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
@@ -65,7 +65,7 @@ class UpdateChecker
private val NOTE_REGEX = Regex("")
- val ACTIVE = true
+ const val ACTIVE = BuildConfig.UPDATING_ENABLED
}
/**
@@ -128,50 +128,67 @@ class UpdateChecker
return Version.fromString(pkgInfo.versionName!!)
}
+ 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? {
- return withContext(Dispatchers.IO) {
+ 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
}
}
@@ -347,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/ui/cards/BannerCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt
index a05af761..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 {
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/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt
index 4c8090e3..ae945726 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
@@ -882,15 +883,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
@@ -1146,3 +1139,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/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 95926eed..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
@@ -206,7 +207,9 @@ fun FilterByButton(
val isSelected =
remember(currentValue) {
when (filterOption) {
- GenreFilter -> {
+ GenreFilter,
+ StudioFilter,
+ -> {
(currentValue as? List)
.orEmpty()
.contains(value.value)
@@ -271,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 e85b1d02..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
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/data/ItemDetailsDialogInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt
index 5cf0789e..8e7c89b1 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
@@ -40,6 +40,7 @@ data class ItemDetailsDialogInfo(
val overview: String?,
val genres: List,
val files: List,
+ val studios: List = emptyList(),
)
/**
@@ -74,6 +75,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(", "),
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 cfae65ba..543d8e2c 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
@@ -54,6 +54,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
@@ -75,6 +76,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 {
@@ -218,6 +247,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) {
@@ -239,6 +269,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 080c1c4e..74fdaa1e 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
@@ -31,6 +31,7 @@ data class MoreDialogActions(
val onSendMediaInfo: (UUID) -> Unit,
val onClickDelete: (BaseItem) -> Unit,
val onClickGoTo: (BaseItem) -> Unit = { navigateTo(it.destination()) },
+ val onClickRemoveFromNextUp: (BaseItem) -> Unit = {},
)
enum class ClearChosenStreams {
@@ -270,6 +271,8 @@ fun buildMoreDialogItemsForHome(
favorite: Boolean,
canDelete: Boolean,
actions: MoreDialogActions,
+ canRemoveContinueWatching: Boolean = false,
+ canRemoveNextUp: Boolean = false,
): List =
buildList {
val itemId = item.id
@@ -347,6 +350,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/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/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt
index 47a80e49..440e8d4a 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,14 +29,13 @@ 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
import com.github.damontecres.wholphin.data.model.aspectRatioFloat
+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.AspectRatios
@@ -67,11 +67,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
@@ -98,16 +99,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 canDelete by viewModel.canDelete.collectAsState()
+ val state by viewModel.state.collectAsState()
var overviewDialog by remember { mutableStateOf(null) }
var moreDialog by remember { mutableStateOf(null) }
@@ -140,196 +132,189 @@ 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 = 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 = 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(
+ title = movie.name ?: unknownStr,
+ overview = movie.data.overview,
+ genres = movie.data.genres.orEmpty(),
+ files = movie.data.mediaSources.orEmpty(),
+ studios = movie.studioNames,
+ )
+ },
+ 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!!,
+ ) { 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 ?: unknownStr,
+ 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)
+ },
+ onClickDiscover = { index, item ->
+ viewModel.navigateTo(item.destination)
+ },
+ canDelete = state.canDelete,
+ deleteOnClick = { showDeleteDialog = state.movie },
+ modifier = modifier,
+ )
}
}
overviewDialog?.let { info ->
@@ -403,13 +388,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,
@@ -454,7 +433,7 @@ fun MovieDetailsContent(
MovieDetailsHeader(
preferences = preferences,
movie = movie,
- chosenStreams = chosenStreams,
+ chosenStreams = state.chosenStreams,
bringIntoViewRequester = bringIntoViewRequester,
overviewOnClick = overviewOnClick,
modifier =
@@ -481,7 +460,7 @@ fun MovieDetailsContent(
}
}
},
- trailers = trailers,
+ trailers = state.trailers,
trailerOnClick = {
position = TRAILER_ROW
trailerOnClick.invoke(it)
@@ -496,7 +475,7 @@ fun MovieDetailsContent(
)
}
}
- if (people.isNotEmpty()) {
+ state.people.letNotEmpty { people ->
item {
PersonRow(
people = people,
@@ -515,7 +494,7 @@ fun MovieDetailsContent(
)
}
}
- if (chapters.isNotEmpty()) {
+ state.chapters.letNotEmpty { chapters ->
item {
ChapterRow(
chapters = chapters,
@@ -532,7 +511,7 @@ fun MovieDetailsContent(
)
}
}
- if (extras.isNotEmpty()) {
+ state.extras.letNotEmpty { extras ->
item {
ExtrasRow(
extras = extras,
@@ -548,7 +527,7 @@ fun MovieDetailsContent(
)
}
}
- if (similar.isNotEmpty()) {
+ state.similar.letNotEmpty { similar ->
item {
val imageHeight =
remember(movie.type) {
@@ -587,13 +566,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/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt
index 131c0cd6..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,9 +1,7 @@
package com.github.damontecres.wholphin.ui.detail.movie
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.ExtrasItem
@@ -30,36 +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.combinePair
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.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
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)
@@ -89,94 +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())
-
- val canDelete = MutableStateFlow(false)
+ private val _state = MutableStateFlow(MovieState())
+ val state: StateFlow = _state
init {
init()
viewModelScope.launchDefault {
- item
- .asFlow()
- .filterNotNull()
- .combinePair(userPreferencesService.flow.map { it.appPreferences })
- .collectLatest { (item, preferences) ->
- canDelete.update { mediaManagementService.canDelete(item, preferences) }
+ 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)
- }
- 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(
@@ -187,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...")
}
}
@@ -227,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) }
}
}
@@ -253,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) }
}
}
@@ -283,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) }
}
}
}
@@ -301,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
index dfd0aa0b..53cdb444 100644
--- 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
@@ -539,6 +539,9 @@ fun AlbumDetailsPage(
onClick = onClick,
onLongClick = onLongClick,
aspectRatio = AspectRatios.WIDE,
+ played = item?.played ?: false,
+ playPercent = item?.data?.userData?.playedPercentage ?: 0.0,
+ favorite = item?.favorite ?: false,
modifier = mod,
)
},
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
index 050373fb..4a338d03 100644
--- 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
@@ -568,6 +568,9 @@ fun ArtistDetailsPage(
onClick = onClick,
onLongClick = onLongClick,
aspectRatio = AspectRatios.WIDE,
+ played = item?.played ?: false,
+ playPercent = item?.data?.userData?.playedPercentage ?: 0.0,
+ favorite = item?.favorite ?: false,
modifier = mod,
)
},
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 3a95440f..91baa312 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
@@ -41,12 +41,14 @@ 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 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
@@ -87,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
@@ -212,6 +215,7 @@ fun SeriesDetails(
title = item.name ?: context.getString(R.string.unknown),
overview = item.data.overview,
genres = item.data.genres.orEmpty(),
+ studios = item.studioNames,
files = listOf(),
)
},
@@ -396,6 +400,7 @@ fun SeriesDetailsContent(
series = series,
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
overviewOnClick = overviewOnClick,
+ bringIntoViewRequester = bringIntoViewRequester,
modifier =
Modifier
.fillMaxWidth()
@@ -653,6 +658,7 @@ fun SeriesDetailsContent(
DiscoverRowData(
stringResource(R.string.discover),
DataLoadingState.Success(discovered),
+ type = DiscoverRequestType.UNKNOWN,
),
onClickItem = { index: Int, item: DiscoverItem ->
position = DISCOVER_ROW
@@ -684,6 +690,7 @@ fun SeriesDetailsHeader(
series: BaseItem,
showLogo: Boolean,
overviewOnClick: () -> Unit,
+ bringIntoViewRequester: BringIntoViewRequester,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
@@ -709,8 +716,16 @@ fun SeriesDetailsHeader(
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 = HeaderUtils.startPadding, bottom = 8.dp))
+ GenreText(it, Modifier.padding(start = HeaderUtils.startPadding, bottom = 4.dp))
}
dto.overview?.let { overview ->
OverviewText(
@@ -718,6 +733,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/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt
index b32bbd30..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
@@ -95,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)
}
@@ -120,6 +122,8 @@ fun SeriesOverviewContent(
}
val focusRequesters = remember(seasons) { List(seasons.size) { FocusRequester() } }
+ val currentOnChangeSeason by rememberUpdatedState(onChangeSeason)
+
Box(
modifier =
modifier
@@ -154,11 +158,14 @@ fun SeriesOverviewContent(
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
@@ -254,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()
@@ -313,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/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 e30be9b3..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)
}
}
@@ -286,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(),
@@ -296,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 94281b78..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
@@ -26,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
@@ -45,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
@@ -52,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
@@ -122,54 +125,78 @@ 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,
@@ -210,6 +237,8 @@ fun HomePage(
)
}
}
+
+ else -> {}
}
}
@@ -239,12 +268,19 @@ fun HomePageContent(
},
) {
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()) {
@@ -266,11 +302,6 @@ fun HomePageContent(
}
}
}
- LaunchedEffect(position) {
- if (position.row >= 0) {
- listState.animateScrollToItem(position.row)
- }
- }
LaunchedEffect(onUpdateBackdrop, focusedItem) {
focusedItem?.let { onUpdateBackdrop.invoke(it) }
}
@@ -280,9 +311,11 @@ fun HomePageContent(
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(
@@ -329,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()
@@ -346,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,
@@ -354,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) },
)
},
)
@@ -503,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 fde8e3aa..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
@@ -232,7 +232,6 @@ 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)
@@ -377,7 +376,7 @@ fun SearchPage(
}
}
searchResultRow(
- title = context.getString(R.string.movies),
+ title = R.string.movies,
result = movies,
rowIndex = MOVIE_ROW,
position = position,
@@ -387,7 +386,7 @@ fun SearchPage(
modifier = Modifier.fillMaxWidth(),
)
searchResultRow(
- title = context.getString(R.string.tv_shows),
+ title = R.string.tv_shows,
result = series,
rowIndex = SERIES_ROW,
position = position,
@@ -397,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,
@@ -414,12 +413,13 @@ fun SearchPage(
},
onLongClick = onLongClick,
imageHeight = 140.dp,
+ showImageOverlay = true,
modifier = mod.padding(horizontal = 8.dp),
)
},
)
searchResultRow(
- title = context.getString(R.string.albums),
+ title = R.string.albums,
result = albums,
rowIndex = ALBUM_ROW,
position = position,
@@ -437,12 +437,13 @@ fun SearchPage(
onLongClick = onLongClick,
imageHeight = Cards.heightEpisode,
aspectRatio = AspectRatios.SQUARE,
+ showImageOverlay = true,
modifier = mod,
)
},
)
searchResultRow(
- title = context.getString(R.string.artists),
+ title = R.string.artists,
result = artists,
rowIndex = COLLECTION_ROW,
position = position,
@@ -460,12 +461,13 @@ fun SearchPage(
onLongClick = onLongClick,
imageHeight = Cards.heightEpisode,
aspectRatio = AspectRatios.SQUARE,
+ showImageOverlay = true,
modifier = mod,
)
},
)
searchResultRow(
- title = context.getString(R.string.songs),
+ title = R.string.songs,
result = songs,
rowIndex = SONG_ROW,
position = position,
@@ -483,12 +485,13 @@ fun SearchPage(
onLongClick = onLongClick,
imageHeight = Cards.heightEpisode,
aspectRatio = AspectRatios.SQUARE,
+ showImageOverlay = true,
modifier = mod,
)
},
)
searchResultRow(
- title = context.getString(R.string.collections),
+ title = R.string.collections,
result = collections,
rowIndex = COLLECTION_ROW,
position = position,
@@ -498,7 +501,7 @@ fun SearchPage(
modifier = Modifier.fillMaxWidth(),
)
searchResultRow(
- title = context.getString(R.string.discover),
+ title = R.string.discover,
result = seerrResults,
rowIndex = SEERR_ROW,
position = position,
@@ -525,7 +528,7 @@ fun SearchPage(
}
fun LazyListScope.searchResultRow(
- title: String,
+ @StringRes title: Int,
result: SearchResult,
rowIndex: Int,
position: RowColumn,
@@ -549,6 +552,7 @@ fun LazyListScope.searchResultRow(
},
onLongClick = onLongClick,
imageHeight = Cards.height2x3,
+ showImageOverlay = true,
modifier = mod,
)
},
@@ -557,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,
@@ -570,7 +574,7 @@ fun LazyListScope.searchResultRow(
SearchResult.Searching -> {
SearchResultPlaceholder(
- title = title,
+ title = stringResource(title),
message = stringResource(R.string.searching),
modifier = modifier,
)
@@ -579,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 = { _, _ -> },
@@ -598,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/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 a2a1ac63..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
@@ -33,6 +33,7 @@ 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
@@ -61,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)
@@ -284,6 +286,9 @@ fun HomeSettingsPage(
addRow(false) { viewModel.resetToDefault() }
}
},
+ onClickViewNextUp = {
+ showRemovedNextUpDialog = true
+ },
modifier = destModifier,
)
}
@@ -340,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 50f34513..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
@@ -323,6 +323,16 @@ 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) }
@@ -414,7 +424,7 @@ class HomeSettingsViewModel
config =
HomeRowConfig.ByParent(
parentId = parent.id,
- recursive = true,
+ recursive = false,
),
)
updateState {
@@ -700,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/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt
index 936ce69f..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,11 @@ 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)
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 5591f56d..1aa92bb3 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,6 +6,7 @@ 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.UserPreferences
import com.github.damontecres.wholphin.ui.components.ItemGrid
@@ -35,6 +36,7 @@ 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
@@ -251,7 +253,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,
)
}
@@ -353,6 +360,14 @@ fun DestinationContent(
}
}
}
+
+ is Destination.DiscoverMoreResult -> {
+ LaunchedEffect(Unit) { onClearBackdrop.invoke() }
+ DiscoverRequestGrid(
+ destination = destination,
+ modifier = modifier,
+ )
+ }
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt
index c214d3cb..18b630f6 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt
@@ -24,7 +24,6 @@ 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.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -55,20 +54,26 @@ import com.github.damontecres.wholphin.preferences.advancedPreferences
import com.github.damontecres.wholphin.preferences.basicPreferences
import com.github.damontecres.wholphin.preferences.screensaverPreferences
import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences
+import com.github.damontecres.wholphin.services.Release
import com.github.damontecres.wholphin.services.SeerrConnectionStatus
import com.github.damontecres.wholphin.services.UpdateChecker
import com.github.damontecres.wholphin.ui.components.ConfirmDialog
+import com.github.damontecres.wholphin.ui.components.ErrorMessage
+import com.github.damontecres.wholphin.ui.components.LoadingPage
+import com.github.damontecres.wholphin.ui.components.ScrollableDialog
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playOnClickSound
import com.github.damontecres.wholphin.ui.playSoundOnFocus
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
+import com.github.damontecres.wholphin.ui.setup.ReleaseNotes
import com.github.damontecres.wholphin.ui.setup.UpdateViewModel
import com.github.damontecres.wholphin.ui.setup.seerr.AddSeerServerDialog
import com.github.damontecres.wholphin.ui.setup.seerr.SwitchSeerrViewModel
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.tryRequestFocus
+import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch
@@ -93,6 +98,7 @@ fun PreferencesContent(
val currentUser by viewModel.currentUser.observeAsState()
val currentServer by seerrVm.currentSeerrServer.collectAsState(null)
var showPinFlow by remember { mutableStateOf(false) }
+ var showVersionDialog by remember { mutableStateOf(false) }
var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) }
val seerrConnection by viewModel.seerrConnection.collectAsState()
@@ -252,15 +258,13 @@ fun PreferencesContent(
}
when (pref) {
AppPreference.InstalledVersion -> {
- var clickCount by remember { mutableIntStateOf(0) }
ClickPreference(
title = stringResource(R.string.installed_version),
onClick = {
- if (movementSounds) playOnClickSound(context)
- if (clickCount++ >= 2) {
- clickCount = 0
- viewModel.navigationManager.navigateTo(Destination.Debug)
- }
+ showVersionDialog = true
+ },
+ onLongClick = {
+ viewModel.navigationManager.navigateTo(Destination.Debug)
},
summary = installedVersion.toString(),
interactionSource = interactionSource,
@@ -615,6 +619,33 @@ fun PreferencesContent(
},
)
}
+ if (showVersionDialog) {
+ LaunchedEffect(Unit) {
+ viewModel.fetchReleaseNotes()
+ }
+ val release by viewModel.releaseNotes.collectAsState()
+ ScrollableDialog(
+ onDismissRequest = { showVersionDialog = false },
+ ) {
+ item {
+ when (val r = release) {
+ is DataLoadingState.Error -> {
+ ErrorMessage(message = "Error", exception = r.exception)
+ }
+
+ DataLoadingState.Pending,
+ DataLoadingState.Loading,
+ -> {
+ LoadingPage()
+ }
+
+ is DataLoadingState.Success -> {
+ ReleaseNotes(r.data)
+ }
+ }
+ }
+ }
+ }
}
@Composable
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt
index 58f9a5e3..80dd7934 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt
@@ -11,10 +11,13 @@ import com.github.damontecres.wholphin.preferences.resetSubtitles
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager
+import com.github.damontecres.wholphin.services.Release
import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.SeerrServerRepository
+import com.github.damontecres.wholphin.services.UpdateChecker
import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs
import com.github.damontecres.wholphin.ui.launchIO
+import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.RememberTabManager
@@ -22,9 +25,11 @@ 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 org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo
+import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
@@ -42,6 +47,7 @@ class PreferencesViewModel
private val seerrServerRepository: SeerrServerRepository,
private val deviceInfo: DeviceInfo,
private val clientInfo: ClientInfo,
+ private val updateChecker: UpdateChecker,
) : ViewModel(),
RememberTabManager by rememberTabManager {
val currentUser get() = serverRepository.currentUser
@@ -51,6 +57,8 @@ class PreferencesViewModel
private val _quickConnectStatus = MutableStateFlow(LoadingState.Pending)
val quickConnectStatus: StateFlow = _quickConnectStatus
+ val releaseNotes = MutableStateFlow>(DataLoadingState.Pending)
+
init {
viewModelScope.launchIO {
serverRepository.currentUser.value?.let { user ->
@@ -99,6 +107,23 @@ class PreferencesViewModel
}
}
+ fun fetchReleaseNotes() {
+ viewModelScope.launchIO {
+ releaseNotes.update { DataLoadingState.Loading }
+ try {
+ val release = updateChecker.getRelease(updateChecker.getInstalledVersion())
+ if (release != null) {
+ releaseNotes.update { DataLoadingState.Success(release) }
+ } else {
+ releaseNotes.update { DataLoadingState.Error("Release not found") }
+ }
+ } catch (ex: Exception) {
+ Timber.e(ex, "Error fetching release")
+ releaseNotes.update { DataLoadingState.Error(ex) }
+ }
+ }
+ }
+
companion object {
suspend fun resetSubtitleSettings(appPreferences: DataStore) {
appPreferences.updateData {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt
index cc6f2d3d..7e5a6ec5 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt
@@ -53,7 +53,6 @@ import com.github.damontecres.wholphin.services.Release
import com.github.damontecres.wholphin.services.UpdateChecker
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.components.BasicDialog
-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.components.TextButton
@@ -66,6 +65,7 @@ import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.Version
import com.mikepenz.markdown.m3.Markdown
+import com.mikepenz.markdown.m3.markdownTypography
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -272,15 +272,7 @@ fun InstallUpdatePageContent(
},
) {
item {
- Markdown(
- (release.notes.joinToString("\n\n") + (release.body ?: ""))
- .replace(
- Regex("https://github.com/damontecres/\\w+/pull/(\\d+)"),
- "#$1",
- )
- // Remove the last line for full changelog since its just a link
- .replace(Regex("\\*\\*Full Changelog\\*\\*.*"), ""),
- )
+ ReleaseNotes(release)
}
}
Column(
@@ -317,6 +309,25 @@ fun InstallUpdatePageContent(
}
}
+@Composable
+fun ReleaseNotes(
+ release: Release,
+ modifier: Modifier = Modifier,
+) {
+ Markdown(
+ content = release.content,
+ typography =
+ markdownTypography(
+ h1 = MaterialTheme.typography.headlineLarge,
+ h2 = MaterialTheme.typography.headlineMedium,
+ h3 = MaterialTheme.typography.headlineSmall,
+ text = MaterialTheme.typography.bodySmall,
+ code = MaterialTheme.typography.bodySmall,
+ ),
+ modifier = modifier,
+ )
+}
+
@Composable
fun DownloadDialog(
contentLength: Long,
@@ -392,13 +403,15 @@ private fun InstallUpdatePageContentPreview() {
downloadUrl = "https://url",
publishedAt = null,
body =
- "Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus " +
+ "## Header 2\n" +
+ "Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus " +
"ex sapien vitae pellentesque sem placerat. In id cursus mi pretium " +
"tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. " +
"Pulvinar vivamus fringilla lacus nec metus bibendum egestas. " +
"Iaculis massa nisl malesuada lacinia integer nunc posuere. " +
"Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora " +
"torquent per conubia nostra inceptos himenaeos.\n\n" +
+ "### Header 3\n" +
"Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus " +
"ex sapien vitae pellentesque sem placerat. In id cursus mi pretium " +
"tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. " +
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt
index 5358a14e..a39cae25 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt
@@ -9,6 +9,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 kotlinx.coroutines.Dispatchers
@@ -16,7 +17,9 @@ import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.genresApi
import org.jellyfin.sdk.api.client.extensions.localizationApi
+import org.jellyfin.sdk.api.client.extensions.studiosApi
import org.jellyfin.sdk.api.client.extensions.yearsApi
+import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import timber.log.Timber
@@ -47,6 +50,16 @@ object FilterUtils {
.map { FilterValueOption(it.name ?: "", it.id) }
}
+ StudioFilter -> {
+ api.studiosApi
+ .getStudios(
+ parentId = parentId,
+ userId = userId,
+ includeItemTypes = listOf(BaseItemKind.SERIES),
+ ).content.items
+ .map { FilterValueOption(it.name ?: "", it.id) }
+ }
+
FavoriteFilter,
PlayedFilter,
-> {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt
index 08f9318e..a64acb70 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt
@@ -1,18 +1,10 @@
package com.github.damontecres.wholphin.util
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableIntStateOf
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.DEFAULT_PAGE_SIZE
-import com.google.common.cache.CacheBuilder
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
+import kotlinx.serialization.Serializable
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.Response
import org.jellyfin.sdk.api.client.extensions.genresApi
@@ -20,6 +12,7 @@ import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.api.client.extensions.personsApi
import org.jellyfin.sdk.api.client.extensions.playlistsApi
+import org.jellyfin.sdk.api.client.extensions.studiosApi
import org.jellyfin.sdk.api.client.extensions.suggestionsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
@@ -32,114 +25,40 @@ import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
+import org.jellyfin.sdk.model.api.request.GetStudiosRequest
import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest
import timber.log.Timber
import java.util.UUID
-import java.util.function.Predicate
/**
- * Handles paging for an API request. You must call [init] prior to use.
- *
- * Initially, items returned will be null, but requesting the items triggers API calls in the given [CoroutineScope].
- * Since the items are stored in [androidx.compose.runtime.MutableState], it will automatically trigger recompositions when the items are ultimately fetched.
- *
- * Finally, items are cached allow for backward and forward scrolling.
+ * A [RequestPager] for Jellyfin server queries
*/
class ApiRequestPager(
val api: ApiClient,
val request: T,
val requestHandler: RequestHandler,
- private val scope: CoroutineScope,
- private val pageSize: Int = DEFAULT_PAGE_SIZE,
+ scope: CoroutineScope,
+ pageSize: Int = DEFAULT_PAGE_SIZE,
cacheSize: Long = 8,
private val useSeriesForPrimary: Boolean = false,
-) : AbstractList(),
- BlockingList {
- private var items by mutableStateOf(ItemList(0, pageSize, mapOf()))
- private var totalCount by mutableIntStateOf(-1)
- private val mutex = Mutex()
- private val cachedPages =
- CacheBuilder
- .newBuilder()
- .maximumSize(cacheSize)
- .build>()
+) : RequestPager(scope, pageSize, cacheSize) {
+ override suspend fun init(initialPosition: Int): ApiRequestPager = super.init(initialPosition) as ApiRequestPager
- suspend fun init(initialPosition: Int = 0): ApiRequestPager {
- if (totalCount < 0) {
- fetchPageBlocking(initialPosition, true)
- }
- return this
- }
-
- override operator fun get(index: Int): BaseItem? {
- if (index in 0..): Int {
- init()
- for (i in 0 until totalCount) {
- val currentItem = getBlocking(i)
- if (currentItem != null && predicate.test(currentItem)) {
- return i
- }
- }
- return -1
- }
-
- override val size: Int
- get() = totalCount
-
- private fun fetchPage(position: Int): Job =
- scope.launch(ExceptionHandler() + Dispatchers.IO) {
- fetchPageBlocking(position, false)
- }
-
- private suspend fun fetchPageBlocking(
- position: Int,
- setTotalCount: Boolean,
- ) {
- mutex.withLock {
- val pageNumber = position / pageSize
- if (cachedPages.getIfPresent(pageNumber) == null || setTotalCount) {
- if (DEBUG) Timber.v("fetchPage: $pageNumber")
- val newRequest =
- requestHandler.prepare(
- request,
- pageNumber * pageSize,
- pageSize,
- setTotalCount,
- )
- val result = requestHandler.execute(api, newRequest).content
- if (setTotalCount) {
- totalCount = result.totalRecordCount.coerceAtLeast(0)
- }
- val data = mutableListOf()
- result.items.forEach { data.add(BaseItem.from(it, api, useSeriesForPrimary)) }
- cachedPages.put(pageNumber, data)
- items = ItemList(totalCount, pageSize, cachedPages.asMap())
- }
- }
+ override suspend fun fetchPage(
+ pageNumber: Int,
+ includeTotalCount: Boolean,
+ ): QueryResult {
+ val newRequest =
+ requestHandler.prepare(
+ request,
+ pageNumber * pageSize,
+ pageSize,
+ includeTotalCount,
+ )
+ val result = requestHandler.execute(api, newRequest).content
+ val data = mutableListOf()
+ result.items.forEach { data.add(BaseItem(it, useSeriesForPrimary)) }
+ return QueryResult(data, result.totalRecordCount)
}
suspend fun refreshItem(
@@ -161,7 +80,7 @@ class ApiRequestPager(
if (page != null && index in page.indices) {
page[index] = item
cachedPages.put(pageNumber, page)
- items = ItemList(totalCount, pageSize, cachedPages.asMap())
+ items = ItemList(size, pageSize, cachedPages.asMap())
}
}
}
@@ -181,39 +100,10 @@ class ApiRequestPager(
}
fetchPageBlocking(position, true)
}
-
- companion object {
- private const val DEBUG = false
- }
-
- class ItemList(
- val size: Int,
- val pageSize: Int,
- val pages: Map>,
- ) {
- operator fun get(position: Int): T? {
- val page = position / pageSize
- val data = pages[page]
- if (data != null) {
- val index = position % pageSize
- if (index in data.indices) {
- return data[index]
- } else {
- // This can happen when items are removed while scrolling
- Timber.w(
- "Index $index not in data: position=$position, data.size=${data.size}",
- )
- return null
- }
- } else {
- return null
- }
- }
- }
}
/**
- * Specifies how the [ApiRequestPager] should prepare and execute API calls
+ * Specifies how a [RequestPager] should prepare and execute API calls
*/
interface RequestHandler {
/**
@@ -235,6 +125,7 @@ interface RequestHandler {
): Response
}
+@Serializable
val GetItemsRequestHandler =
object : RequestHandler {
override fun prepare(
@@ -255,6 +146,7 @@ val GetItemsRequestHandler =
): Response = api.itemsApi.getItems(request)
}
+@Serializable
val GetEpisodesRequestHandler =
object : RequestHandler {
override fun prepare(
@@ -274,6 +166,7 @@ val GetEpisodesRequestHandler =
): Response = api.tvShowsApi.getEpisodes(request)
}
+@Serializable
val GetResumeItemsRequestHandler =
object : RequestHandler {
override fun prepare(
@@ -294,6 +187,7 @@ val GetResumeItemsRequestHandler =
): Response = api.itemsApi.getResumeItems(request)
}
+@Serializable
val GetNextUpRequestHandler =
object : RequestHandler {
override fun prepare(
@@ -314,6 +208,7 @@ val GetNextUpRequestHandler =
): Response = api.tvShowsApi.getNextUp(request)
}
+@Serializable
val GetSuggestionsRequestHandler =
object : RequestHandler {
override fun prepare(
@@ -334,6 +229,7 @@ val GetSuggestionsRequestHandler =
): Response = api.suggestionsApi.getSuggestions(request)
}
+@Serializable
val GetPlaylistItemsRequestHandler =
object : RequestHandler {
override fun prepare(
@@ -353,6 +249,7 @@ val GetPlaylistItemsRequestHandler =
): Response = api.playlistsApi.getPlaylistItems(request)
}
+@Serializable
val GetGenresRequestHandler =
object : RequestHandler {
override fun prepare(
@@ -392,6 +289,7 @@ val GetProgramsDtoHandler =
): Response = api.liveTvApi.getPrograms(request)
}
+@Serializable
val GetPersonsHandler =
object : RequestHandler {
override fun prepare(
@@ -410,3 +308,23 @@ val GetPersonsHandler =
request: GetPersonsRequest,
): Response = api.personsApi.getPersons((request))
}
+
+val GetStudiosRequestHandler =
+ object : RequestHandler {
+ override fun prepare(
+ request: GetStudiosRequest,
+ startIndex: Int,
+ limit: Int,
+ enableTotalRecordCount: Boolean,
+ ): GetStudiosRequest =
+ request.copy(
+ startIndex = startIndex,
+ limit = limit,
+ enableTotalRecordCount = enableTotalRecordCount,
+ )
+
+ override suspend fun execute(
+ api: ApiClient,
+ request: GetStudiosRequest,
+ ): Response = api.studiosApi.getStudios(request)
+ }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/DiscoverRequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/DiscoverRequestPager.kt
new file mode 100644
index 00000000..cee890ce
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/util/DiscoverRequestPager.kt
@@ -0,0 +1,111 @@
+package com.github.damontecres.wholphin.util
+
+import androidx.annotation.StringRes
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.api.seerr.model.MovieResult
+import com.github.damontecres.wholphin.api.seerr.model.TvResult
+import com.github.damontecres.wholphin.data.model.DiscoverItem
+import com.github.damontecres.wholphin.services.SeerrApi
+import com.github.damontecres.wholphin.services.SeerrSearchResult
+import kotlinx.coroutines.CoroutineScope
+
+/**
+ * A [RequestPager] for Seerr server queries
+ */
+class DiscoverRequestPager(
+ private val api: SeerrApi,
+ val requestHandler: DiscoverRequestHandler,
+ val transform: suspend (T) -> DiscoverItem,
+ scope: CoroutineScope,
+ pageSize: Int = SEERR_PAGE_SIZE,
+ cacheSize: Long = 16,
+) : RequestPager(scope, pageSize, cacheSize) {
+ override suspend fun init(initialPosition: Int): DiscoverRequestPager = super.init(initialPosition) as DiscoverRequestPager
+
+ override suspend fun fetchPage(
+ pageNumber: Int,
+ includeTotalCount: Boolean,
+ ): QueryResult {
+ val result = requestHandler.execute(api, pageNumber + 1) // Seerr pages are 1-indexed
+ val transformed = result.items.map { transform.invoke(it) }
+ return QueryResult(transformed, result.totalCount)
+ }
+}
+
+const val SEERR_PAGE_SIZE = 20
+
+enum class DiscoverRequestType(
+ @param:StringRes val stringRes: Int,
+) {
+ DISCOVER_TV(R.string.discover_tv),
+ DISCOVER_MOVIES(R.string.discover_movies),
+ TRENDING(R.string.trending),
+ UPCOMING_TV(R.string.upcoming_tv),
+ UPCOMING_MOVIES(R.string.upcoming_movies),
+ UNKNOWN(R.string.unknown),
+}
+
+/**
+ * Specifies how a [RequestPager] should prepare and execute API calls
+ */
+interface DiscoverRequestHandler {
+ suspend fun execute(
+ api: SeerrApi,
+ pageNumber: Int,
+ ): QueryResult
+}
+
+val DiscoverTvRequestHandler =
+ object : DiscoverRequestHandler {
+ override suspend fun execute(
+ api: SeerrApi,
+ pageNumber: Int,
+ ): QueryResult =
+ api.api.searchApi.discoverTvGet(page = pageNumber).let {
+ QueryResult(it.results.orEmpty(), it.totalResults ?: 0)
+ }
+ }
+
+val DiscoverMovieRequestHandler =
+ object : DiscoverRequestHandler {
+ override suspend fun execute(
+ api: SeerrApi,
+ pageNumber: Int,
+ ): QueryResult =
+ api.api.searchApi.discoverMoviesGet(page = pageNumber).let {
+ QueryResult(it.results.orEmpty(), it.totalResults ?: 0)
+ }
+ }
+
+val TrendingRequestHandler =
+ object : DiscoverRequestHandler {
+ override suspend fun execute(
+ api: SeerrApi,
+ pageNumber: Int,
+ ): QueryResult =
+ api.api.searchApi.discoverTrendingGet(page = pageNumber).let {
+ QueryResult(it.results.orEmpty(), it.totalResults ?: 0)
+ }
+ }
+
+val UpcomingTvRequestHandler =
+ object : DiscoverRequestHandler {
+ override suspend fun execute(
+ api: SeerrApi,
+ pageNumber: Int,
+ ): QueryResult =
+ api.api.searchApi.discoverTvUpcomingGet(page = pageNumber).let {
+ QueryResult(it.results.orEmpty(), it.totalResults ?: 0)
+ }
+ }
+
+val UpcomingMovieRequestHandler =
+ object : DiscoverRequestHandler {
+ override suspend fun execute(
+ api: SeerrApi,
+ pageNumber: Int,
+ ): QueryResult =
+ api.api.searchApi.discoverMoviesUpcomingGet(page = pageNumber).let {
+ QueryResult(it.results.orEmpty(), it.totalResults ?: 0)
+ }
+ }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt
index 5da98fec..97308113 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt
@@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.util
import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.data.model.HomeRowConfig
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
/**
@@ -64,6 +65,7 @@ sealed interface HomeRowLoadingState {
override val title: String,
val items: List,
val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
+ val rowType: HomeRowConfig? = null,
) : HomeRowLoadingState
data class Error(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/LocalDateSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/util/LocalDateSerializer.kt
index 89b900f0..b88bcb8a 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/util/LocalDateSerializer.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/util/LocalDateSerializer.kt
@@ -6,6 +6,7 @@ import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.time.LocalDate
+import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
object LocalDateSerializer : KSerializer {
@@ -22,3 +23,20 @@ object LocalDateSerializer : KSerializer {
override fun deserialize(decoder: Decoder): LocalDate =
decoder.decodeString().let { LocalDate.parse(it, DateTimeFormatter.ISO_LOCAL_DATE) }
}
+
+object LocalDateTimeSerializer : KSerializer {
+ override val descriptor: SerialDescriptor
+ get() = SerialDescriptor("LocalDateTime", String.serializer().descriptor)
+
+ override fun serialize(
+ encoder: Encoder,
+ value: LocalDateTime,
+ ) {
+ encoder.encodeString(DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(value))
+ }
+
+ override fun deserialize(decoder: Decoder): LocalDateTime =
+ decoder
+ .decodeString()
+ .let { LocalDateTime.parse(it, DateTimeFormatter.ISO_LOCAL_DATE_TIME) }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/RequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/RequestPager.kt
new file mode 100644
index 00000000..c9f91534
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/util/RequestPager.kt
@@ -0,0 +1,148 @@
+package com.github.damontecres.wholphin.util
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import com.github.damontecres.wholphin.ui.DEFAULT_PAGE_SIZE
+import com.google.common.cache.CacheBuilder
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import timber.log.Timber
+import java.util.function.Predicate
+
+/**
+ * Handles paging for an API request. You must call [init] prior to use.
+ *
+ * Initially, items returned will be null, but requesting the items triggers API calls in the given [CoroutineScope].
+ * Since the items are stored in [androidx.compose.runtime.MutableState], it will automatically trigger recompositions when the items are ultimately fetched.
+ *
+ * Finally, items are cached allow for backward and forward scrolling.
+ */
+abstract class RequestPager(
+ protected val scope: CoroutineScope,
+ protected val pageSize: Int = DEFAULT_PAGE_SIZE,
+ cacheSize: Long = 8,
+) : AbstractList(),
+ BlockingList {
+ protected var totalCount by mutableIntStateOf(-1)
+ protected var items by mutableStateOf(ItemList(0, pageSize, mapOf()))
+ protected val mutex = Mutex()
+ protected val cachedPages =
+ CacheBuilder
+ .newBuilder()
+ .maximumSize(cacheSize)
+ .build>()
+
+ open suspend fun init(initialPosition: Int = 0): RequestPager {
+ if (totalCount < 0) {
+ fetchPageBlocking(initialPosition, true)
+ }
+ return this
+ }
+
+ override operator fun get(index: Int): T? {
+ if (index in 0..): Int {
+ init(0)
+ for (i in 0 until totalCount) {
+ val currentItem = getBlocking(i)
+ if (currentItem != null && predicate.test(currentItem)) {
+ return i
+ }
+ }
+ return -1
+ }
+
+ override val size: Int
+ get() = totalCount
+
+ private fun fetchPage(position: Int): Job =
+ scope.launch(ExceptionHandler() + Dispatchers.IO) {
+ fetchPageBlocking(position, false)
+ }
+
+ protected suspend fun fetchPageBlocking(
+ position: Int,
+ setTotalCount: Boolean,
+ ) {
+ mutex.withLock {
+ val pageNumber = position / pageSize
+ if (cachedPages.getIfPresent(pageNumber) == null || setTotalCount) {
+ if (DEBUG) Timber.v("fetchPage: $pageNumber")
+ val result = fetchPage(pageNumber, setTotalCount)
+ if (setTotalCount && result.totalCount != null) {
+ totalCount = result.totalCount.coerceAtLeast(0)
+ }
+ cachedPages.put(pageNumber, result.items.toMutableList())
+ items = ItemList(totalCount, pageSize, cachedPages.asMap())
+ }
+ }
+ }
+
+ protected abstract suspend fun fetchPage(
+ pageNumber: Int,
+ includeTotalCount: Boolean,
+ ): QueryResult
+
+ companion object {
+ internal const val DEBUG = false
+ }
+
+ class ItemList(
+ val size: Int,
+ val pageSize: Int,
+ val pages: Map>,
+ ) {
+ operator fun get(position: Int): T? {
+ val page = position / pageSize
+ val data = pages[page]
+ if (data != null) {
+ val index = position % pageSize
+ if (index in data.indices) {
+ return data[index]
+ } else {
+ // This can happen when items are removed while scrolling
+ Timber.w(
+ "Index $index not in data: position=$position, data.size=${data.size}",
+ )
+ return null
+ }
+ } else {
+ return null
+ }
+ }
+ }
+}
+
+data class QueryResult(
+ val items: List,
+ val totalCount: Int?,
+)
diff --git a/app/src/main/jni/Android.mk b/app/src/main/jni/Android.mk
deleted file mode 100644
index 798ae9a2..00000000
--- a/app/src/main/jni/Android.mk
+++ /dev/null
@@ -1,82 +0,0 @@
-LOCAL_PATH:= $(call my-dir)
-
-ifeq ($(TARGET_ARCH_ABI),armeabi-v7a)
-PREFIX = $(PREFIX32)
-endif
-ifeq ($(TARGET_ARCH_ABI),arm64-v8a)
-PREFIX = $(PREFIX64)
-endif
-ifeq ($(TARGET_ARCH_ABI),x86_64)
-PREFIX = $(PREFIX_X64)
-endif
-ifeq ($(TARGET_ARCH_ABI),x86)
-PREFIX = $(PREFIX_X86)
-endif
-
-include $(CLEAR_VARS)
-LOCAL_MODULE := libswresample
-LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so
-include $(PREBUILT_SHARED_LIBRARY)
-
-include $(CLEAR_VARS)
-LOCAL_MODULE := libpostproc
-LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so
-# only include if library file exists
-ifneq (,$(wildcard $(LOCAL_SRC_FILES)))
-include $(PREBUILT_SHARED_LIBRARY)
-endif
-
-include $(CLEAR_VARS)
-LOCAL_MODULE := libavutil
-LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so
-include $(PREBUILT_SHARED_LIBRARY)
-
-include $(CLEAR_VARS)
-LOCAL_MODULE := libavcodec
-LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so
-include $(PREBUILT_SHARED_LIBRARY)
-
-include $(CLEAR_VARS)
-LOCAL_MODULE := libavformat
-LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so
-include $(PREBUILT_SHARED_LIBRARY)
-
-include $(CLEAR_VARS)
-LOCAL_MODULE := libswscale
-LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so
-include $(PREBUILT_SHARED_LIBRARY)
-
-include $(CLEAR_VARS)
-LOCAL_MODULE := libavfilter
-LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so
-include $(PREBUILT_SHARED_LIBRARY)
-
-include $(CLEAR_VARS)
-LOCAL_MODULE := libavdevice
-LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so
-LOCAL_EXPORT_C_INCLUDES := $(PREFIX)/include
-include $(PREBUILT_SHARED_LIBRARY)
-
-include $(CLEAR_VARS)
-LOCAL_MODULE := libmpv
-LOCAL_SRC_FILES := $(PREFIX)/lib/libmpv.so
-LOCAL_EXPORT_C_INCLUDES := $(PREFIX)/include
-include $(PREBUILT_SHARED_LIBRARY)
-
-include $(CLEAR_VARS)
-
-LOCAL_MODULE := libplayer
-LOCAL_CFLAGS := -Werror
-LOCAL_CPPFLAGS += -std=c++11
-LOCAL_SRC_FILES := \
- main.cpp \
- render.cpp \
- log.cpp \
- jni_utils.cpp \
- property.cpp \
- event.cpp \
- thumbnail.cpp
-LOCAL_LDLIBS := -llog -lGLESv3 -lEGL -latomic
-LOCAL_SHARED_LIBRARIES := swscale avcodec mpv
-
-include $(BUILD_SHARED_LIBRARY)
diff --git a/app/src/main/jni/Application.mk b/app/src/main/jni/Application.mk
deleted file mode 100644
index 53418cb4..00000000
--- a/app/src/main/jni/Application.mk
+++ /dev/null
@@ -1,17 +0,0 @@
-APP_ABI :=
-ifneq ($(PREFIX32),)
-APP_ABI += armeabi-v7a
-endif
-ifneq ($(PREFIX64),)
-APP_ABI += arm64-v8a
-endif
-ifneq ($(PREFIX_X64),)
-APP_ABI += x86_64
-endif
-ifneq ($(PREFIX_X86),)
-APP_ABI += x86
-endif
-
-APP_PLATFORM := android-21
-APP_STL := c++_shared
-APP_SUPPORT_FLEXIBLE_PAGE_SIZES := true
diff --git a/app/src/main/jni/README.md b/app/src/main/jni/README.md
deleted file mode 100644
index 15d82972..00000000
--- a/app/src/main/jni/README.md
+++ /dev/null
@@ -1,16 +0,0 @@
-# JNI bindings for libmpv
-
-The files in this directory are copied from https://github.com/mpv-android/mpv-android/tree/master/app/src/main/jni
-
-## License & copyright
-
-The files are copyrighted and used under MIT license below:
-
-Copyright (c) 2016 Ilya Zhuravlev
-Copyright (c) 2016 sfan5
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/app/src/main/jni/event.cpp b/app/src/main/jni/event.cpp
deleted file mode 100644
index 26c69300..00000000
--- a/app/src/main/jni/event.cpp
+++ /dev/null
@@ -1,121 +0,0 @@
-#include
-
-#include
-
-#include "globals.h"
-#include "jni_utils.h"
-#include "log.h"
-
-static void sendPropertyUpdateToJava(JNIEnv *env, mpv_event_property *prop)
-{
- jstring jprop = env->NewStringUTF(prop->name);
- jstring jvalue = NULL;
- switch (prop->format) {
- case MPV_FORMAT_NONE:
- env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_eventProperty_S, jprop);
- break;
- case MPV_FORMAT_FLAG:
- env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_eventProperty_Sb, jprop,
- (jboolean) (*(int*)prop->data != 0));
- break;
- case MPV_FORMAT_INT64:
- env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_eventProperty_Sl, jprop,
- (jlong) *(int64_t*)prop->data);
- break;
- case MPV_FORMAT_DOUBLE:
- env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_eventProperty_Sd, jprop,
- (jdouble) *(double*)prop->data);
- break;
- case MPV_FORMAT_STRING:
- jvalue = env->NewStringUTF(*(const char**)prop->data);
- env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_eventProperty_SS, jprop, jvalue);
- break;
- default:
- ALOGV("sendPropertyUpdateToJava: Unknown property update format received in callback: %d!", prop->format);
- break;
- }
- if (jprop)
- env->DeleteLocalRef(jprop);
- if (jvalue)
- env->DeleteLocalRef(jvalue);
-}
-
-static void sendEventToJava(JNIEnv *env, int event)
-{
- env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_event, event);
-}
-
-static void sendEndFileEventToJava(JNIEnv *env, mpv_event_end_file *event)
-{
- env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_end_file_event, event->reason, event->error);
-}
-
-static void sendLogMessageToJava(JNIEnv *env, mpv_event_log_message *msg)
-{
- // filter the most obvious cases of invalid utf-8, since Java would choke on it
- const auto invalid_utf8 = [] (unsigned char c) {
- return c == 0xc0 || c == 0xc1 || c >= 0xf5;
- };
- for (int i = 0; msg->text[i]; i++) {
- if (invalid_utf8(static_cast(msg->text[i])))
- return;
- }
-
- jstring jprefix = env->NewStringUTF(msg->prefix);
- jstring jtext = env->NewStringUTF(msg->text);
-
- env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_logMessage_SiS,
- jprefix, (jint) msg->log_level, jtext);
-
- if (jprefix)
- env->DeleteLocalRef(jprefix);
- if (jtext)
- env->DeleteLocalRef(jtext);
-}
-
-void *event_thread(void *arg)
-{
- JNIEnv *env = NULL;
- acquire_jni_env(g_vm, &env);
- if (!env)
- die("failed to acquire java env");
-
- while (1) {
- mpv_event *mp_event;
- mpv_event_property *mp_property = NULL;
- mpv_event_log_message *msg = NULL;
- mpv_event_end_file *mp_end_file = NULL;
-
- mp_event = mpv_wait_event(g_mpv, -1.0);
-
- if (g_event_thread_request_exit)
- break;
-
- if (mp_event->event_id == MPV_EVENT_NONE)
- continue;
-
- switch (mp_event->event_id) {
- case MPV_EVENT_LOG_MESSAGE:
- msg = (mpv_event_log_message*)mp_event->data;
- ALOGV("[%s:%s] %s", msg->prefix, msg->level, msg->text);
- sendLogMessageToJava(env, msg);
- break;
- case MPV_EVENT_PROPERTY_CHANGE:
- mp_property = (mpv_event_property*)mp_event->data;
- sendPropertyUpdateToJava(env, mp_property);
- break;
- case MPV_EVENT_END_FILE:
- mp_end_file = (mpv_event_end_file*)mp_event->data;
- sendEndFileEventToJava(env, mp_end_file);
- break;
- default:
- ALOGV("event: %s\n", mpv_event_name(mp_event->event_id));
- sendEventToJava(env, mp_event->event_id);
- break;
- }
- }
-
- g_vm->DetachCurrentThread();
-
- return NULL;
-}
diff --git a/app/src/main/jni/event.h b/app/src/main/jni/event.h
deleted file mode 100644
index ebb20d2d..00000000
--- a/app/src/main/jni/event.h
+++ /dev/null
@@ -1,3 +0,0 @@
-#pragma once
-
-void *event_thread(void *arg);
diff --git a/app/src/main/jni/globals.h b/app/src/main/jni/globals.h
deleted file mode 100644
index e2427b8b..00000000
--- a/app/src/main/jni/globals.h
+++ /dev/null
@@ -1,7 +0,0 @@
-#pragma once
-
-#include
-
-extern JavaVM *g_vm;
-extern mpv_handle *g_mpv;
-extern std::atomic g_event_thread_request_exit;
diff --git a/app/src/main/jni/jni_utils.cpp b/app/src/main/jni/jni_utils.cpp
deleted file mode 100644
index f878fdff..00000000
--- a/app/src/main/jni/jni_utils.cpp
+++ /dev/null
@@ -1,52 +0,0 @@
-#define UTIL_EXTERN
-#include "jni_utils.h"
-
-#include
-#include
-
-bool acquire_jni_env(JavaVM *vm, JNIEnv **env)
-{
- int ret = vm->GetEnv((void**) env, JNI_VERSION_1_6);
- if (ret == JNI_EDETACHED)
- return vm->AttachCurrentThread(env, NULL) == 0;
- else
- return ret == JNI_OK;
-}
-
-// Apparently it's considered slow to FindClass and GetMethodID every time we need them,
-// so let's have a nice cache here.
-
-void init_methods_cache(JNIEnv *env)
-{
- static bool methods_initialized = false;
- if (methods_initialized)
- return;
-
- #define FIND_CLASS(name) reinterpret_cast(env->NewGlobalRef(env->FindClass(name)))
- java_Integer = FIND_CLASS("java/lang/Integer");
- java_Integer_init = env->GetMethodID(java_Integer, "", "(I)V");
- java_Double = FIND_CLASS("java/lang/Double");
- java_Double_init = env->GetMethodID(java_Double, "", "(D)V");
- java_Boolean = FIND_CLASS("java/lang/Boolean");
- java_Boolean_init = env->GetMethodID(java_Boolean, "", "(Z)V");
-
- android_graphics_Bitmap = FIND_CLASS("android/graphics/Bitmap");
- // createBitmap(int[], int, int, android.graphics.Bitmap$Config)
- android_graphics_Bitmap_createBitmap = env->GetStaticMethodID(android_graphics_Bitmap, "createBitmap", "([IIILandroid/graphics/Bitmap$Config;)Landroid/graphics/Bitmap;");
- android_graphics_Bitmap_Config = FIND_CLASS("android/graphics/Bitmap$Config");
- // static final android.graphics.Bitmap$Config ARGB_8888
- android_graphics_Bitmap_Config_ARGB_8888 = env->GetStaticFieldID(android_graphics_Bitmap_Config, "ARGB_8888", "Landroid/graphics/Bitmap$Config;");
-
- mpv_MPVLib = FIND_CLASS("com/github/damontecres/wholphin/util/mpv/MPVLib");
- mpv_MPVLib_eventProperty_S = env->GetStaticMethodID(mpv_MPVLib, "eventProperty", "(Ljava/lang/String;)V"); // eventProperty(String)
- mpv_MPVLib_eventProperty_Sb = env->GetStaticMethodID(mpv_MPVLib, "eventProperty", "(Ljava/lang/String;Z)V"); // eventProperty(String, boolean)
- mpv_MPVLib_eventProperty_Sl = env->GetStaticMethodID(mpv_MPVLib, "eventProperty", "(Ljava/lang/String;J)V"); // eventProperty(String, long)
- mpv_MPVLib_eventProperty_Sd = env->GetStaticMethodID(mpv_MPVLib, "eventProperty", "(Ljava/lang/String;D)V"); // eventProperty(String, double)
- mpv_MPVLib_eventProperty_SS = env->GetStaticMethodID(mpv_MPVLib, "eventProperty", "(Ljava/lang/String;Ljava/lang/String;)V"); // eventProperty(String, String)
- mpv_MPVLib_event = env->GetStaticMethodID(mpv_MPVLib, "event", "(I)V"); // event(int)
- mpv_MPVLib_end_file_event = env->GetStaticMethodID(mpv_MPVLib, "eventEndFile", "(II)V"); // eventEndFile(int, int)
- mpv_MPVLib_logMessage_SiS = env->GetStaticMethodID(mpv_MPVLib, "logMessage", "(Ljava/lang/String;ILjava/lang/String;)V"); // logMessage(String, int, String)
- #undef FIND_CLASS
-
- methods_initialized = true;
-}
diff --git a/app/src/main/jni/jni_utils.h b/app/src/main/jni/jni_utils.h
deleted file mode 100644
index ed9d025d..00000000
--- a/app/src/main/jni/jni_utils.h
+++ /dev/null
@@ -1,30 +0,0 @@
-#pragma once
-
-#include
-
-#define jni_func_name(name) Java_com_github_damontecres_wholphin_util_mpv_MPVLib_##name
-#define jni_func(return_type, name, ...) JNIEXPORT return_type JNICALL jni_func_name(name) (JNIEnv *env, jobject obj, ##__VA_ARGS__)
-
-bool acquire_jni_env(JavaVM *vm, JNIEnv **env);
-void init_methods_cache(JNIEnv *env);
-
-#ifndef UTIL_EXTERN
-#define UTIL_EXTERN extern
-#endif
-
-UTIL_EXTERN jclass java_Integer, java_Double, java_Boolean;
-UTIL_EXTERN jmethodID java_Integer_init, java_Double_init, java_Boolean_init;
-
-UTIL_EXTERN jclass android_graphics_Bitmap, android_graphics_Bitmap_Config;
-UTIL_EXTERN jmethodID android_graphics_Bitmap_createBitmap;
-UTIL_EXTERN jfieldID android_graphics_Bitmap_Config_ARGB_8888;
-
-UTIL_EXTERN jclass mpv_MPVLib;
-UTIL_EXTERN jmethodID mpv_MPVLib_eventProperty_S,
- mpv_MPVLib_eventProperty_Sb,
- mpv_MPVLib_eventProperty_Sl,
- mpv_MPVLib_eventProperty_Sd,
- mpv_MPVLib_eventProperty_SS,
- mpv_MPVLib_event,
- mpv_MPVLib_end_file_event,
- mpv_MPVLib_logMessage_SiS;
diff --git a/app/src/main/jni/log.cpp b/app/src/main/jni/log.cpp
deleted file mode 100644
index 240af1bc..00000000
--- a/app/src/main/jni/log.cpp
+++ /dev/null
@@ -1,9 +0,0 @@
-#include "log.h"
-
-#include
-
-void die(const char *msg)
-{
- ALOGE("%s", msg);
- exit(1);
-}
diff --git a/app/src/main/jni/log.h b/app/src/main/jni/log.h
deleted file mode 100644
index bef941b2..00000000
--- a/app/src/main/jni/log.h
+++ /dev/null
@@ -1,20 +0,0 @@
-#pragma once
-
-#include
-
-#define DEBUG 0
-
-#define LOG_TAG "mpv"
-#define ALOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
-#if DEBUG
-#define ALOGV(...) __android_log_print(ANDROID_LOG_VERBOSE, LOG_TAG, __VA_ARGS__)
-#else
-#define ALOGV(...) (void)0
-#endif
-
-__attribute__((noreturn)) void die(const char *msg);
-
-#define CHECK_MPV_INIT() do { \
- if (__builtin_expect(!g_mpv, 0)) \
- die("libmpv is not initialized"); \
- } while (0)
diff --git a/app/src/main/jni/main.cpp b/app/src/main/jni/main.cpp
deleted file mode 100644
index 5f3faedc..00000000
--- a/app/src/main/jni/main.cpp
+++ /dev/null
@@ -1,108 +0,0 @@
-#include
-#include
-#include
-#include
-#include
-#include
-
-#include
-
-#include
-
-extern "C" {
- #include
-}
-
-#include "log.h"
-#include "jni_utils.h"
-#include "event.h"
-
-#define ARRAYLEN(a) (sizeof(a)/sizeof(a[0]))
-
-extern "C" {
- jni_func(void, create, jobject appctx);
- jni_func(void, init);
- jni_func(void, destroy);
-
- jni_func(void, command, jobjectArray jarray);
-};
-
-JavaVM *g_vm;
-mpv_handle *g_mpv;
-std::atomic g_event_thread_request_exit(false);
-
-static pthread_t event_thread_id;
-
-static void prepare_environment(JNIEnv *env, jobject appctx) {
- setlocale(LC_NUMERIC, "C");
-
- if (!env->GetJavaVM(&g_vm) && g_vm)
- av_jni_set_java_vm(g_vm, NULL);
-
- jobject global_appctx = env->NewGlobalRef(appctx);
- if (global_appctx)
- av_jni_set_android_app_ctx(global_appctx, NULL);
-
- init_methods_cache(env);
-}
-
-jni_func(void, create, jobject appctx) {
- prepare_environment(env, appctx);
-
- if (g_mpv)
- die("mpv is already initialized");
-
- g_mpv = mpv_create();
- if (!g_mpv)
- die("context init failed");
-
- // use terminal log level but request verbose messages
- // this way --msg-level can be used to adjust later
- mpv_request_log_messages(g_mpv, "terminal-default");
- mpv_set_option_string(g_mpv, "msg-level", "all=v");
-}
-
-jni_func(void, init) {
- if (!g_mpv)
- die("mpv is not created");
-
- if (mpv_initialize(g_mpv) < 0)
- die("mpv init failed");
-
- g_event_thread_request_exit = false;
- if (pthread_create(&event_thread_id, NULL, event_thread, NULL) != 0)
- die("thread create failed");
- pthread_setname_np(event_thread_id, "event_thread");
-}
-
-jni_func(void, destroy) {
- if (!g_mpv) {
- ALOGV("mpv destroy called but it's already destroyed");
- return;
- }
-
- // poke event thread and wait for it to exit
- g_event_thread_request_exit = true;
- mpv_wakeup(g_mpv);
- pthread_join(event_thread_id, NULL);
-
- mpv_terminate_destroy(g_mpv);
- g_mpv = NULL;
-}
-
-jni_func(void, command, jobjectArray jarray) {
- CHECK_MPV_INIT();
-
- const char *arguments[128] = {0};
- int len = env->GetArrayLength(jarray);
- if (len >= ARRAYLEN(arguments))
- die("too many command arguments");
-
- for (int i = 0; i < len; ++i)
- arguments[i] = env->GetStringUTFChars((jstring)env->GetObjectArrayElement(jarray, i), NULL);
-
- mpv_command(g_mpv, arguments);
-
- for (int i = 0; i < len; ++i)
- env->ReleaseStringUTFChars((jstring)env->GetObjectArrayElement(jarray, i), arguments[i]);
-}
diff --git a/app/src/main/jni/property.cpp b/app/src/main/jni/property.cpp
deleted file mode 100644
index a9558ab6..00000000
--- a/app/src/main/jni/property.cpp
+++ /dev/null
@@ -1,125 +0,0 @@
-#include
-#include
-
-#include
-
-#include "jni_utils.h"
-#include "log.h"
-#include "globals.h"
-
-extern "C" {
- jni_func(jint, setOptionString, jstring option, jstring value);
-
- jni_func(jobject, getPropertyInt, jstring property);
- jni_func(void, setPropertyInt, jstring property, jint value);
- jni_func(jobject, getPropertyDouble, jstring property);
- jni_func(void, setPropertyDouble, jstring property, jdouble value);
- jni_func(jobject, getPropertyBoolean, jstring property);
- jni_func(void, setPropertyBoolean, jstring property, jboolean value);
- jni_func(jstring, getPropertyString, jstring jproperty);
- jni_func(void, setPropertyString, jstring jproperty, jstring jvalue);
-
- jni_func(void, observeProperty, jstring property, jint format);
-}
-
-jni_func(jint, setOptionString, jstring joption, jstring jvalue) {
- CHECK_MPV_INIT();
-
- const char *option = env->GetStringUTFChars(joption, NULL);
- const char *value = env->GetStringUTFChars(jvalue, NULL);
-
- int result = mpv_set_option_string(g_mpv, option, value);
-
- env->ReleaseStringUTFChars(joption, option);
- env->ReleaseStringUTFChars(jvalue, value);
-
- return result;
-}
-
-static int common_get_property(JNIEnv *env, jstring jproperty, mpv_format format, void *output)
-{
- CHECK_MPV_INIT();
-
- const char *prop = env->GetStringUTFChars(jproperty, NULL);
- int result = mpv_get_property(g_mpv, prop, format, output);
- if (result == MPV_ERROR_PROPERTY_UNAVAILABLE)
- ALOGV("mpv_get_property(%s) format %d was unavailable", prop, format);
- else if (result < 0)
- ALOGE("mpv_get_property(%s) format %d returned error %s", prop, format, mpv_error_string(result));
- env->ReleaseStringUTFChars(jproperty, prop);
-
- return result;
-}
-
-static int common_set_property(JNIEnv *env, jstring jproperty, mpv_format format, void *value)
-{
- CHECK_MPV_INIT();
-
- const char *prop = env->GetStringUTFChars(jproperty, NULL);
- int result = mpv_set_property(g_mpv, prop, format, value);
- if (result < 0)
- ALOGE("mpv_set_property(%s, %p) format %d returned error %s", prop, value, format, mpv_error_string(result));
- env->ReleaseStringUTFChars(jproperty, prop);
-
- return result;
-}
-
-jni_func(jobject, getPropertyInt, jstring jproperty) {
- int64_t value = 0;
- if (common_get_property(env, jproperty, MPV_FORMAT_INT64, &value) < 0)
- return NULL;
- return env->NewObject(java_Integer, java_Integer_init, (jint)value);
-}
-
-jni_func(jobject, getPropertyDouble, jstring jproperty) {
- double value = 0;
- if (common_get_property(env, jproperty, MPV_FORMAT_DOUBLE, &value) < 0)
- return NULL;
- return env->NewObject(java_Double, java_Double_init, (jdouble)value);
-}
-
-jni_func(jobject, getPropertyBoolean, jstring jproperty) {
- int value = 0;
- if (common_get_property(env, jproperty, MPV_FORMAT_FLAG, &value) < 0)
- return NULL;
- return env->NewObject(java_Boolean, java_Boolean_init, (jboolean)value);
-}
-
-jni_func(jstring, getPropertyString, jstring jproperty) {
- char *value;
- if (common_get_property(env, jproperty, MPV_FORMAT_STRING, &value) < 0)
- return NULL;
- jstring jvalue = env->NewStringUTF(value);
- mpv_free(value);
- return jvalue;
-}
-
-jni_func(void, setPropertyInt, jstring jproperty, jint jvalue) {
- int64_t value = static_cast(jvalue);
- common_set_property(env, jproperty, MPV_FORMAT_INT64, &value);
-}
-
-jni_func(void, setPropertyDouble, jstring jproperty, jdouble jvalue) {
- double value = static_cast(jvalue);
- common_set_property(env, jproperty, MPV_FORMAT_DOUBLE, &value);
-}
-
-jni_func(void, setPropertyBoolean, jstring jproperty, jboolean jvalue) {
- int value = jvalue == JNI_TRUE ? 1 : 0;
- common_set_property(env, jproperty, MPV_FORMAT_FLAG, &value);
-}
-
-jni_func(void, setPropertyString, jstring jproperty, jstring jvalue) {
- const char *value = env->GetStringUTFChars(jvalue, NULL);
- common_set_property(env, jproperty, MPV_FORMAT_STRING, &value);
- env->ReleaseStringUTFChars(jvalue, value);
-}
-
-jni_func(void, observeProperty, jstring property, jint format) {
- CHECK_MPV_INIT();
- const char *prop = env->GetStringUTFChars(property, NULL);
- int result = mpv_observe_property(g_mpv, 0, prop, (mpv_format)format);
- if (result < 0)
- ALOGE("mpv_observe_property(%s) format %d returned error %s", prop, format, mpv_error_string(result));
- env->ReleaseStringUTFChars(property, prop);
-}
diff --git a/app/src/main/jni/render.cpp b/app/src/main/jni/render.cpp
deleted file mode 100644
index 1df2bb5a..00000000
--- a/app/src/main/jni/render.cpp
+++ /dev/null
@@ -1,38 +0,0 @@
-#include
-
-#include
-
-#include "jni_utils.h"
-#include "log.h"
-#include "globals.h"
-
-extern "C" {
- jni_func(void, attachSurface, jobject surface_);
- jni_func(void, detachSurface);
-};
-
-static jobject surface;
-
-jni_func(void, attachSurface, jobject surface_) {
- CHECK_MPV_INIT();
-
- surface = env->NewGlobalRef(surface_);
- if (!surface)
- die("invalid surface provided");
- int64_t wid = reinterpret_cast(surface);
- int result = mpv_set_option(g_mpv, "wid", MPV_FORMAT_INT64, &wid);
- if (result < 0)
- ALOGE("mpv_set_option(wid) returned error %s", mpv_error_string(result));
-}
-
-jni_func(void, detachSurface) {
- CHECK_MPV_INIT();
-
- int64_t wid = 0;
- int result = mpv_set_option(g_mpv, "wid", MPV_FORMAT_INT64, &wid);
- if (result < 0)
- ALOGE("mpv_set_option(wid) returned error %s", mpv_error_string(result));
-
- env->DeleteGlobalRef(surface);
- surface = NULL;
-}
diff --git a/app/src/main/jni/thumbnail.cpp b/app/src/main/jni/thumbnail.cpp
deleted file mode 100644
index d1156f8e..00000000
--- a/app/src/main/jni/thumbnail.cpp
+++ /dev/null
@@ -1,133 +0,0 @@
-#include
-#include
-
-#include
-#include
-#include
-
-extern "C" {
- #include
-};
-
-#include "jni_utils.h"
-#include "globals.h"
-#include "log.h"
-
-extern "C" {
- jni_func(jobject, grabThumbnail, jint dimension);
-};
-
-static inline mpv_node make_node_str(const char *s)
-{
- mpv_node r{};
- r.format = MPV_FORMAT_STRING;
- r.u.string = const_cast(s);
- return r;
-}
-
-jni_func(jobject, grabThumbnail, jint dimension) {
- CHECK_MPV_INIT();
-
- mpv_node result{};
- {
- mpv_node c{}, c_args[2];
- mpv_node_list c_array{};
- c_args[0] = make_node_str("screenshot-raw");
- c_args[1] = make_node_str("video");
- c_array.num = 2;
- c_array.values = c_args;
- c.format = MPV_FORMAT_NODE_ARRAY;
- c.u.list = &c_array;
- if (mpv_command_node(g_mpv, &c, &result) < 0) {
- ALOGE("screenshot-raw command failed");
- return NULL;
- }
- }
-
- // extract relevant property data from the node map mpv returns
- int w = 0, h = 0, stride = 0;
- bool format_ok = false;
- struct mpv_byte_array *data = NULL;
- do {
- if (result.format != MPV_FORMAT_NODE_MAP)
- break;
- for (int i = 0; i < result.u.list->num; i++) {
- std::string key(result.u.list->keys[i]);
- const mpv_node *val = &result.u.list->values[i];
- if (key == "w" || key == "h" || key == "stride") {
- if (val->format != MPV_FORMAT_INT64)
- break;
- if (key == "w")
- w = val->u.int64;
- else if (key == "h")
- h = val->u.int64;
- else
- stride = val->u.int64;
- } else if (key == "format") {
- if (val->format != MPV_FORMAT_STRING)
- break;
- format_ok = !strcmp(val->u.string, "bgr0");
- } else if (key == "data") {
- if (val->format != MPV_FORMAT_BYTE_ARRAY)
- break;
- data = val->u.ba;
- }
- }
- } while (0);
- if (!w || !h || !stride || !format_ok || !data) {
- ALOGE("extracting data failed");
- mpv_free_node_contents(&result);
- return NULL;
- }
- ALOGV("screenshot w:%d h:%d stride:%d", w, h, stride);
-
- // crop to square
- int crop_left = 0, crop_top = 0;
- int new_w = w, new_h = h;
- if (w > h) {
- crop_left = (w - h) / 2;
- new_w = h;
- } else {
- crop_top = (h - w) / 2;
- new_h = w;
- }
- ALOGV("cropped w:%u h:%u", new_w, new_h);
-
- uint8_t *new_data = reinterpret_cast(data->data);
- new_data += crop_left * sizeof(uint32_t); // move begin rightwards
- new_data += stride * crop_top; // move begin downwards
-
- // convert & scale to appropriate size
- struct SwsContext *ctx = sws_getContext(
- new_w, new_h, AV_PIX_FMT_BGR0,
- dimension, dimension, AV_PIX_FMT_RGB32,
- SWS_BICUBIC, NULL, NULL, NULL);
- if (!ctx) {
- mpv_free_node_contents(&result);
- return NULL;
- }
-
- jintArray arr = env->NewIntArray(dimension * dimension);
- jint *scaled = env->GetIntArrayElements(arr, NULL);
-
- uint8_t *src_p[4] = { new_data }, *dst_p[4] = { (uint8_t*) scaled };
- int src_stride[4] = { stride },
- dst_stride[4] = { (int) sizeof(jint) * dimension };
- sws_scale(ctx, src_p, src_stride, 0, new_h, dst_p, dst_stride);
- sws_freeContext(ctx);
-
- mpv_free_node_contents(&result); // frees data->data
-
- // create android.graphics.Bitmap
- env->ReleaseIntArrayElements(arr, scaled, 0);
-
- jobject bitmap_config =
- env->GetStaticObjectField(android_graphics_Bitmap_Config, android_graphics_Bitmap_Config_ARGB_8888);
- jobject bitmap =
- env->CallStaticObjectMethod(android_graphics_Bitmap, android_graphics_Bitmap_createBitmap,
- arr, dimension, dimension, bitmap_config);
- env->DeleteLocalRef(arr);
- env->DeleteLocalRef(bitmap_config);
-
- return bitmap;
-}
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index 327f969b..bab3e3d7 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -66,11 +66,11 @@
المزيد
- الأفلام
-
-
-
-
-
+
+
+
+
+
الاسم
التالي
@@ -85,11 +85,11 @@
المسار
- الأشخاص
-
-
-
-
-
+
+
+
+
+
عدد مرات التشغيل
تشغيل من هنا
@@ -128,4 +128,26 @@
خطأ في الشبكة
انتهت مهلة الشبكة
لم يتم التعرف على الكلام
+ التعرف على الصوت مشغول حالياً
+ خطأ في الخادم
+ لم يتم اكتشاف صوت
+ خطأ غير معروف
+ فشل بدء التعرف على الصوت
+ انتهت مهلة التعرف على الصوت
+ إعادة المحاولة
+ اختر الخادم
+ اختر المستخدم
+ إظهار معلومات تصحيح الأخطاء
+ إظهار التالي
+ إظهار
+ تشغيل عشوائي
+ تخطي
+ تاريخ الإضافة
+ تاريخ إضافة الحلقة
+ تاريخ التشغيل
+ تاريخ الإصدار
+ الاسم
+ عشوائي
+ استوديوهات
+ إرسال
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 628c465a..70aa278d 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -32,7 +32,7 @@
Keine geplanten Aufnahmen
Kein Update verfügbar
Pfad
- Personen
+ Darsteller
Zuletzt hinzugefügt in %1$s
Zuletzt hinzugefügt
Zuletzt aufgenommen
@@ -84,7 +84,7 @@
%1$s Wählen
Bilder Cache leeren
Community Bewertung
- Es ist ein Fehler aufgetreten! Drücke den Knopf um Logs zu deinem Server zu senden.
+ Ein Fehler ist aufgetreten! Drücke die Taste um Logs an deinen Server zu senden.
Regie von %1$s
Deaktiviert
Gefundene Server
@@ -118,7 +118,7 @@
Als nächstes
Keine
Von hier wiedergeben
- Wiedergeben
+ Abspielen
Wiedergabegeschwindigkeit
Wiedergabe
Vorschau
@@ -132,21 +132,21 @@
Debug Infos anzeigen
- Zufall
+ Zufällig
Überspringen
- Datum hinzugefügt
- Datum Episode hinzugefügt
- Datum abgespielt
+ Hinzugefügt am
+ Episode hinzugefügt am
+ Wiedergegeben am
Erscheinungsdatum
Zufällig
- Absenden
- Der Download benötigt eine längere Zeit, evtl. musst du die Wiedergabe erneut starten
+ Übermitteln
+ Herunterladen dauert sehr lange, vielleicht hilft ein Neustart der Wiedergabe
Untertitel
Vorschläge
Wechseln
- Top Bewertet Ungesehen
- Fernsehprogramm
- Mit Transkodierung abspielen
+ Am besten bewertet ungesehen
+ Guide
+ Mit Transcoding wiedergeben
Uhr anzeigen
Neue Playlist erstellen
Zur Playlist hinzufügen
@@ -183,15 +183,15 @@
Live TV
Outro
Wiedergabe Anzahl
- Zeige Wiedergabe Debug infos
+ Wiedergabe Debug Infos anzeigen
Wiedergabeliste
Wiedergabelisten
Zeige als nächstes
Zeigen
Name
Studios
- DVR Zeitplan
- Videogröße
+ Aufnahmeplan
+ Video Skalierung
Video
Videos
Nach Sendedatum sortiert
@@ -223,12 +223,12 @@
- - Shorts
- - Kurzvideos
+ - Short
+ - Shorts
Trifft nur auf Serien zu
- Libass für ASS Untertitel verwenden
- Direktwiedergabe PGS Untertitel
+ Direktes abspielen von ASS Untertitel
+ Direktes abspielen von PGS Untertitel
Immer zu Stereo heruntermischen
FFmpeg decoder Modul verwenden
Standard Skalierung
@@ -250,10 +250,10 @@
Aktualisierungs-URL
Schriftgröße
Schriftfarbe
- Schriftart Deckkraft
+ Deckkraft der Schrift
Stil der Umrandung
Farbe der Umrandung
- Hintergrund Deckktraft
+ Deckkraft des Hintergrundes
Hintergrund Stil
Untertitel Stil
Hintergrundfarbe
@@ -406,7 +406,7 @@
Auflösungswechsel
Gaststars
Ab Anfang
- keine Fotos
+ Keine Fotos
Links Rotieren
Rechts Rotieren
Rot
@@ -439,21 +439,21 @@
Interlaced
NAL
- Gewähre anderen Geräten Zugriff auf deinen Account
- Eingabe Quick Connect Code
- Keine Beschränkung
+ Gewähre anderen Geräten Zugang zu deinem Account
+ Quick Connect-Code eingeben
+ Keine Begrenzung
Reihe hinzufügen
Genres in %1$s
kürzlich veröffentlicht in %1$s
Höhe
Auf alle Reihen anwenden
- Reihe hinzufügen für %1$s
- Überschreibe Einstellungen auf dem Server?
- Überschreibe lokale Einstellungen?
+ Füge Reihe für %1$s hinzu
+ Einstellungen auf dem Server übverschreiben?
+ Lokale Einstellungen überschreiben?
Vorschläge für %1$s
Sende Media Info Log zum Server
Anpassung Homepage
- Für Episoden
+ Für Folgen
Vorlagen um schnell alle Reihen zu gestalten
Wähle Reihen und Bilder auf der Homepage
Starte anschließend
@@ -542,13 +542,40 @@
Arrangeur
Ingenieur
Dunkelblau
- Untertitel-Transparenz
+ Transparenz des Bilduntertitels
Vorschaubild
Serien Vorschaubilder verwenden
Ersteller
- Gestreckt
- Zentriert
+ Textmarker
+ Kasten
Mischer
+ Alle auswählen
+ Alben
+ Interpreten
+ Lieder
+ Zu Interpret wechseln
+ Läuft gerade
+ Sofortmix
+ Zu Album wechseln
+ Zur Warteschlange hinzufügen
+ Album Interpret
+ Album
+ Beliebte Lieder
+ Als nächstes abspielen
+ Musikvideos
+ Liedtexte
+ Liedtexte ausblenden
+ Liedtexte einblenden
+ Lied hat Liedtext
+ Aus Warteschlange entfernen
+ Album Cover anzeigen
+ Visualizer anzeigen
+ Hintergrundbild anzeigen
+ Abspielen als?
+ Die Visualisierung der aktuellen Wiedergabe erfordert die Berechtigung zur Audioaufzeichnung.
+ Fortfahren
+ Verschiedene Arten
+ Logos anstatt Titelnamen anzeigen
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index e228ac4d..13147e59 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -406,7 +406,7 @@
DD
DD+
DTS
- Reproducción directa Dolby Vision Profile 7
+ Direct play Dolby Vision Profile 7
Ignora las comprobaciones de compatibilidad del dispositivo
Descubrir
Solicitar
@@ -569,4 +569,31 @@
¿Estás seguro de que quieres eliminar este elemento?
Mostrar opciones de gestión de medios
Buscar %s
+ Seleccionar todo
+ Mostrar portada del álbum
+ Mostrar visualizador
+ Mostrar imagen de fondo
+ Reproducir en
+ Para visualizar el audio en reproducción, se requiere permiso para grabar audio.
+ Continuar
+ Álbumes
+ Artistas
+ Canciones
+ Ir al artista
+ Reproduciendo
+ Mezcla instantánea
+ Ir al álbum
+ Añadir a la cola
+ Artista del álbum
+ Álbum
+ Más escuchadas
+ Reproducir a continuación
+ Videoclips
+ Letras
+ Ocultar letras
+ Mostrar letras
+ Canción con letra
+ Eliminar de la cola
+ Separar por tipos
+ Preferir logotipos para los títulos
diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml
index 85a803a8..eab60eff 100644
--- a/app/src/main/res/values-et/strings.xml
+++ b/app/src/main/res/values-et/strings.xml
@@ -464,4 +464,92 @@
Rakenduses kaasasolevad eelseadistused kõikide ridade kiireks muutmiseks
Vali kodulehe/avalehe read ja pildid
Märgi %s lemmikuks
+
+ - %s minut
+ - %s minutit
+
+ Purpurpunane
+ Oranž
+ Tugev sinine
+ Must
+ Eira
+ Jäta automaatselt vahele
+ Vahelejätmiseks küsi
+ Kui ühtegi kaasnevat dekoodrit pole, siis kasuta vaid FFmpeg-i
+ Eelista kaasnevatele dekoodritele FFmpeg-i
+ Ära iialgi kasuta dekoderimiseks FFmpeg-i
+ Taasesituse lõpus
+ Valge
+ Helehall
+ Tumehall
+ Kollane
+ Rohekassinine
+ Fuksiapunane
+ Äärisjoon
+ Vari
+ Reamurdmine
+ Eelista MPV-d
+ HDR-i taasesituseks kasuta ExoPlayerit
+ Plakat (2:3)
+ 16:9
+ 4:3
+ Ruut (1:1)
+ Sõrmejälg
+ Esmane
+ Pilt dünaamiliste värvidega
+ Ainult pilt
+
+ API võti
+ Logi sisse Seerri serverisse
+ Jellyfini kasutaja
+ Kohalik kasutaja
+
+ Kaugseadmest ei leidnunud subtiitreid
+ Kasuta rakendusesisest ekraanisäästjat
+ Kas sa oled kindel, et soovid selle objekti kustutada?
+ Näida meediumi haldamise valikuid
+ Näitleja
+ Helilooja
+ Kirjanik
+ Külalisesineja
+ Tootja
+ Dirigent
+ Laulusõnade autor
+ Seade autor
+ Heliinsener
+ Helimiksija
+ Looja
+ Kunstnik
+ Otsi: %s
+ Vali kõik
+ Ruudustatud
+ Ekraanisäästja seadistused
+ Käivita ekraanisäästja
+ Kestus
+ Fotod
+ Kaasa tüübid
+ Sobita
+ Kadreeri
+ Täida
+ Täida laiuses
+ Täida kõrguses
+ Wholphini vaikeseadistus
+ Wholphini kompaktne vaade
+ Sarja pisipildid
+ Osa pisipildid
+ Vaikseim
+ Vaikne
+ Keskmine
+ Vali
+ Täisvaljus
+ Käivita peale
+ Animeeri
+ Ülemine vanusepiirang
+ Puudub
+ Sobilik kõikidele vanustele
+ Kuni vanuseni %s
+ Ekraanisäästja
+ Lõputiitrite ja lõpuklipi ajal
+ Jätkuv sari
+ Mis tüüpi objekte peaks piltide kontekstis näitama
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 312caa8e..05968332 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -197,7 +197,7 @@
Échelle de contenu par défaut
Installer la mise à jour
Version installée
- Nombre maximal d\'éléments par ligne sur la page d\'accueil
+ Eléments sur les lignes de la page d\'accueil
Choisissez les éléments à afficher par défaut, les autres seront masqués
Personnaliser les éléments du menu de navigation
Cliquez pour changer de page
@@ -566,4 +566,8 @@
Mélangeur
Créateur
Artiste
+ Êtes-vous sûr de vouloir supprimer cet élément ?
+ Afficher les options de gestion des médias
+ Recherche %s
+ Tout sélectionner
diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml
index dc94ff96..ae739f25 100644
--- a/app/src/main/res/values-in/strings.xml
+++ b/app/src/main/res/values-in/strings.xml
@@ -522,4 +522,16 @@
Teknisi
Mixer
Kreator
+ Pilih semua
+ Cari %s
+ Tampilkan opsi kelola media
+ Apakah kamu yakin ingin menghapus item ini?
+ Bayangan
+ Pembungkus
+ Kotak
+ Sesuaikan
+ Pangkas
+ Layar penuh
+ Lebar penuh
+ Tinggi penuh
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index 596d898f..604a9ed5 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -569,4 +569,5 @@
Cerca %s
Sei sicuro di voler cancellare questo elemento?
Mostra opzioni di gestione dei media
+ Seleziona tutti
diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml
index 51d5978a..de80b56d 100644
--- a/app/src/main/res/values-iw/strings.xml
+++ b/app/src/main/res/values-iw/strings.xml
@@ -18,10 +18,10 @@
מסחרי
דירוג קהילתי
אירעה שגיאה! לחץ על הכפתור על מנת לשלוח יומני אירועים לשרת שלך.
- לאשר
+ אשר
המשך צפייה
דירוג מבקרים
- %.1f שניות
+ %.2f שניות
ברירת מחדל
מחק
מת
@@ -30,4 +30,115 @@
מושבת
נולד
בחר %1$s
+ בחר שרת
+ שרתים שנמצאו
+ מחפש…
+ הזן כתובת שרת
+ בחר משתמש
+ החלף שרתים
+ חיפוש
+ דף הבית
+ מועדפים
+
+ - סרטים
+
+
+
+
+ - סדרות
+
+
+
+ עונות
+ עונה
+ טריילר
+
+ - טריילרים
+
+
+
+ כתובית
+ כתוביות
+ ממשק
+ התחבר אוטומטית
+ זכור כרטיסיות שנבחרו
+ מופעל
+ נמוך ביותר
+ נמוך
+ בינוני
+ גבוה
+ מלא
+ סגול
+ כתום
+ שחור
+ לבן
+ התחבר
+ שם משתמש
+ סיסמה
+ מוריד…
+ מסתיים ב-%1$s
+ הזן כתובת IP או URL
+ פרקים
+ חיצוני
+ גודל
+ ז\'אנרים
+ עבור לסדרה
+ עבור אל
+ הסתר
+ שגיאה בטעינת האוסף %1$s
+ פתיח
+ תכנים
+ עוד
+ שם
+ הבא בתור
+ אין נתונים
+ אין תוצאות
+ לא נמצאו שרתים
+ סיום
+ ללא
+
+ - אנשים
+
+
+
+ נגן מכאן
+ נגן
+ מהירות ניגון
+ ניגון
+ פלייליסט
+ פלייליסטים
+
+ - הורדה %s
+ - %s הורדות
+ - %s הורדות
+
+
+ - שעה %d
+ - שעתיים
+ - %d שעות
+
+
+ - יום %s
+ - יומיים
+ - %s ימים
+
+
+ - פריט %d
+ - %d פריטים
+ - %d פריטים
+
+
+ - שניה %d
+ - %d שניות
+ - %d שניות
+
+
+ - דקה %s
+ - %s דקות
+ - %s דקות
+
+ סמן כלא נצפה
+ סמן כנצפה
+ מקסימום קצב נתונים
+ עוד דומים
diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml
new file mode 100644
index 00000000..92cefdbe
--- /dev/null
+++ b/app/src/main/res/values-lv/strings.xml
@@ -0,0 +1,278 @@
+
+
+ Par
+ Aktīvie ieraksti
+ Izlase
+ Pievienot Serveri
+ Pievienot Lietotāju
+ Audio
+ Dzimšanas vieta
+ Bitu ātrums
+ Dzimis
+ Atcelt Ierakstu
+ Atcelt Seriālu Ierakstu
+ Atcelt
+ Nodaļas
+ Izvēlēties %1$s
+ Dzēst attēlu kešatmiņu
+ Kolekcija
+ Kolekcijas
+ Reklāma
+ Skatītāju vērtējums
+ Radās kļūda! Nospiediet pogu, lai sūtītu žurnālus uz savu serveri.
+ Apstiprināt
+ Turpināt skatīties
+ Kritiķu vērtējums
+ %.2f sekundes
+ Noklusējums
+ Dzēst
+ Miris
+ Režisējis %1$s
+ Režisors
+ Atslēgts
+ Atrastie serveri
+
+ Lejupielādē…
+ Ieslēgts
+ Beigsies %1$s
+ Ievadi Servera IP vai URL
+ Ievadi servera adresi
+ Sērijas
+ Kļūda, ielādējot kolekciju %1$s
+ Ārējais
+ Izlases
+ Izmērs
+ Piespiedu
+ Žanri
+ Iet uz sērijām
+ Iet Uz
+ Paslēpt atskaņošanas kontroles
+ Paslēpt atkļūdošanas info
+ Paslēpt
+ Sākums
+ Tūlītēja
+ Ievads
+ Bibliotēka
+ Licences informācija
+ Tiešraides
+ Ielādē…
+ Atzīmēt visas sērijas kā redzētas?
+ Atzīmēt visas sērijas kā neredzētas?
+ Atzīmēt kā neredzētu
+ Atzīmēt kā redzētu
+ Maksimālais bitu ātrums
+ Līdzīgs saturs
+ Vairāk
+
+ - Filmas
+
+
+
+ Nosaukums
+ Rindā
+ Nav datu
+ Nav rezultātu
+ Serveri netika atrasti
+ Nav ieplānoti ieraksti
+ Nav pieejami atjauninājumi
+ Neviens
+ Tikai Piespiedu Subtitri
+ Nobeigums
+ Ceļš
+
+ - Cilvēki
+
+
+
+ Skatījumu skaits
+ Atskaņot no šejienes
+ Atskaņot
+ Rādīt atkļūdošanas informāciju
+ Atskaņošanas ātrums
+ Atskaņošana
+ Saraksts
+ Saraksti
+ Priekšskatījums
+ Lietotāja Profila Iestatījumi
+ Rinda
+ Atskats
+ Nesen pievienots sadaļā %1$s
+ Nesen pievienots
+ Nesenie Ieraksti
+ Nesen Iznācis
+ Ieteicamie
+ Ierakstīt Raidījumu
+ Ierakstīt Sērijas
+ Noņemt no izlases
+ Restartēt
+ Turpināt
+ Saglabāt
+
+ Meklēt
+ Meklē…
+ Balss meklēšana
+ Uzsāk
+ Runā lai meklētu
+ Apstrādā
+ Spied atpakaļ lai atceltu
+ Audio ieraksta kļūda
+ Balss atpazīšanas kļūda
+ Mikrofona tiesību kļūda
+ Tīkla kļūda
+ Tīkla noilgums
+ Balss netika atpazīta
+ Balss atpazīšana aizņemta
+ Servera kļūda
+ Neviena balss netika atklāta
+ Nezināma kļūda
+ Kļūda sākot balss atpazīšanu
+ Balss atpazīšanai iestājies noilgums
+ Mēģināt vēlreiz
+ Izvēlies Serveri
+ Izvēlies Lietotāju
+ Rādīt atkļūdošanas info
+ Rādīt \"Skaties tālāk\"
+ Rādīt
+ Sajaukt
+ Izlaist
+ Pievienošanas Datums
+ Sērijas pievienošanas datums
+ Atskaņošanas Datums
+ Izdošanas Datums
+ Nosaukums
+ Nejaušs
+ Studija
+ Iesniegt
+ Lejupielāde ir lēna, iespējams jāmēģina restartēt video
+ Subtitri
+ Subtitri
+ Ieteikumi
+ Mainīt serverus
+ Pārslēgt
+ Augstāk novērtētie un neredzētie
+ Treileris
+
+ - Treileri
+
+
+
+ Ierakstu plāns
+ Programma
+ Sezona
+ Sezonas
+
+ - TV Šovi
+
+
+
+ Saskarne
+ Nezināms
+ Atjauninājumi
+ Versija
+ Video Mērogs
+ Video
+ Video
+ Skatīties tiešraidi
+ %1$d gadus vecs
+ Atskaņot ar transkodēšanu
+ Informācija par failu
+ Rādīt Pulksteni
+ Iznākušo Sēriju Secība
+ Izveidot jaunu izlasi
+ Pievienot izlasei
+ Apturēt ar vienu klikšķi
+ Nospiediet pults vidu, lai pauzētu/atskaņotu
+ Slīpraksts
+ Teksts
+ Fons
+ Veiksmīgi
+ Vecuma ierobežojums
+ Ilgums
+ Ekstras
+
+ - Cits
+
+
+
+
+ - Aizkadri
+
+
+
+
+ - Tituldziesmas
+
+
+
+
+ - Titulvideo
+
+
+
+
+ - Klipi
+
+
+
+
+ - Izgrieztās ainas
+
+
+
+
+ - Intervijas
+
+
+
+
+ - Ainas
+
+
+
+
+ - Paraugi
+
+
+
+
+ - Īssižeti
+
+
+
+
+ - Īsie rullīši
+
+
+
+ Izlase %s
+
+ - %s lejupielāžu
+ - %s lejupielāde
+ - %s lejupielādes
+
+
+ - %d stundas
+ - %d stunda
+ - %d stundas
+
+
+ - %s dienas
+ - %s diena
+ - %s dienas
+
+
+ - %d elementi
+ - %d elements
+ - %d elementi
+
+
+ - %d sekundes
+ - %d sekunde
+ - %d sekundes
+
+
+ - %s minūtes
+ - %s minūte
+ - %s minūtes
+
+
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index 7068d379..1afae518 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -2,7 +2,7 @@
Over
Actieve opnames
- Toev. aan favorieten
+ Aan favorieten toevoegen
Server toevoegen
Gebruiker toevoegen
Audio
@@ -18,11 +18,11 @@
Collectie
Collecties
Reclame
- Gemeenschapsbeoordeling
+ Beoordeling (publiek)
Er is een fout opgetreden. Druk op de knop om de logboeken naar je server te versturen.
Bevestigen
- Hervatten
- Critici-beoordeling
+ Verder kijken
+ Beoordeling (recensie)
%.1f seconden
Standaard
Verwijderen
@@ -30,24 +30,24 @@
Geregisseerd door %1$s
Regisseur
Uitgeschakeld
- Aangetroffen servers
+ Gevonden servers
Bezig met downloaden…
Ingeschakeld
- Voer het IP-adres of de url van de server in
+ Voer IP-adres of url van server in
Afleveringen
- ‘%1$s’ kan niet worden geladen
+ Fout bij laden %1$s collectie
Extern
Favorieten
Grootte
- Afgedwongen
+ Geforceerd
Genres
Ga naar serie
Ga naar
Afspeelbediening verbergen
Foutopsporingsinformatie verbergen
Verbergen
- Overzicht
+ Home
Onmiddellijk
Intro
#ABCDEFGHIJKLMNOPQRSTUVWXYZ
@@ -55,16 +55,16 @@
Licentie-informatie
Live-tv
Bezig met laden…
- Wil je de gehele serie als bekeken markeren?
- Wil je de gehele serie als niet-bekeken markeren?
+ Serie als bekeken markeren?
+ Serie als niet-bekeken markeren?
Markeren als niet-bekeken
Markeren als bekeken
Max. bitsnelheid
- Gerelateerd
+ Soortgelijk
Meer
Films
Naam
- Hierna
+ Volgende
Geen informatie
Geen zoekresultaten
Geen ingeplande opnames
@@ -74,7 +74,7 @@
Locatie
Personen
Aantal keer bekeken
- Hervatten vanaf hier
+ Vanaf hier afspelen
Afspelen
Foutopsporingsinformatie tonen
Afspeelsnelheid
@@ -84,21 +84,21 @@
Voorvertoning
Profielinstellingen
Wachtrij
- Samenvatten
- Onlangs toegevoegd aan ‘%1$s’
+ Samenvatting
+ Onlangs toegevoegd aan %1$s
Onlangs toegevoegd
Onlangs opgenomen
Onlangs uitgebracht
Aanbevolen
Programma opnemen
Serie opnemen
- Verw. uit favorieten
+ Uit favorieten verwijderen
Herstarten
Hervatten
Opslaan
Zoeken
- Bezig met zoeken…
+ Aan het zoeken…
Kies een server
Kies een gebruiker
Foutopsporingsinformatie tonen
@@ -106,10 +106,10 @@
Tonen
Willekeurig
Overslaan
- Toegevoegd op
- Toegevoegd op (aflevering)
- Bekeken op
- Uitgebracht op
+ Toegevoegd
+ Toegevoegd (aflevering)
+ Bekeken
+ Uitgebracht
Naam
Willekeurig
Studio\'s
@@ -130,7 +130,7 @@
Gids
Seizoen
Seizoenen
- Tv-shows
+ Afleveringen
Vormgeving
Onbekend
Updates
@@ -144,14 +144,14 @@
Klok tonen
Volgorde van uitgezonden afleveringen
Afspeellijst maken
- Toevoegen aan afspeellijst
+ Aan afspeellijst toevoegen
Pauzeren met één druk op de knop
Druk op de middelste knop van het d-pad om af te spelen/te pauzeren
Cursief lettertype gebruiken
Lettertype
Achtergrond
Voltooid
- Kijkwijzerbeoordeling
+ Kijkwijzer
Duur
Extra\'s
@@ -354,4 +354,91 @@
Favoriete kanalen eerst tonen
Kanalen sorteren op onlangs bekeken
Programma\'s voorzien van kleurcode
+ Eindigt om %1$s
+ Voer server-adres in
+ Ontdekken
+ Draai naar links
+ Draai naar rechts
+ Zoom uit
+ Negeren
+ Vraag om overslaan
+ Favoriete %s
+ Duur
+ Rij toevoegen
+ Alle leeftijden
+ Gebruikersnaam
+ Leeftijd tot %s
+ Verzadiging
+ Gast hoofdrol
+ Verwijder uit wachtrij
+ Login bij Seerr server
+ Zoek %s
+ Acteur
+ Afspeel voorkeuren wissen
+ Aflevering thumbnails
+ Auteur
+ Voor afleveringen
+ Start na
+ Toevoegen aan wachtrij
+ Componist
+ Serie thumbnails
+ Weet u zeker dat u dit item wilt verwijderen?
+ Instellingen opgeslagen
+ Ga door
+ Muziekvideos
+ Max. aantal dagen in Volgende
+ Wordt nu afgespeeld
+ Aanvragen
+ Trailer afspelen
+ Geen servers gevonden
+ Alleen geforceerde ondertiteling
+ Opnieuw proberen
+ Vertraging ondertiteling
+ Geen trailers
+ In behandeling
+ Wachtwoord
+ Verwachte films
+ Verwachte series
+ Verzoek in 4K
+ Helderheid
+ Stuur media-info log naar server
+ Resolutie schakelen
+ Lokaal
+ Achtergrondstijl
+ Negeer compatibiliteitscontrole apparaat
+ Gesproken zoekopdracht
+ Spreek om te zoeken
+ Starten
+ Verwerken
+ Terugknop om te annuleren
+ Audio opnamefout
+ Spraakherkenningsfout
+ Toestemming microfoon vereist
+ Netwerkfout
+ Netwerk timeout
+ Geen spraak herkent
+ Spraakherkenning bezig
+ Serverfout
+ Geen spraak gedetecteerd
+ Onbekende fout
+ Fout bij starten spraakherkenning
+ Spraakherkenning timed out
+ Seerr integratie
+ Verwijder Seerr server
+ Seerr server toegevoegd
+ HDR ondertitelstijl
+ Dekking ondertitelbeeld
+ Zoom in
+ Geen limiet
+ Startscherm aanpassen
+ Startrijen
+ "Aanbevelingen voor %1$s"
+ Laagst
+ Automatisch overslaan
+ Dirigent
+ Producent
+
+ - %s dag
+ - %s dagen
+
diff --git a/app/src/main/res/values-nn/strings.xml b/app/src/main/res/values-nn/strings.xml
index b4826c5b..12154c8a 100644
--- a/app/src/main/res/values-nn/strings.xml
+++ b/app/src/main/res/values-nn/strings.xml
@@ -359,4 +359,188 @@
Vel standardelementa som skal visast, andre vert skjulde
Sluttar %1$s
+ Berre påtvinga undertekstar
+ Stemmesøk
+ Startar
+ Snakk for å søkje
+ Jobbar
+ Trykk tilbake for å avbryta
+ Lydopptaksfeil
+ Feil med talegjenkjenning
+ Treng mikrofontilgang
+ Nettverksfeil
+ Tidsavbrot på nettverket
+ Fann inga tale
+ Talegjenkjenning er oppteken
+ Tenarfeil
+ Inga tale funne
+ Ukjend feil
+ Klarte ikkje starta talegjenkjenning
+ Tidsavbrot for talegjenkjenning
+ Prøv på nytt
+ Favoritt %s
+ Oppløysingsbyte
+ Lokal
+ Spel trailer
+ Ingen trailerar
+ Tøm sporval
+ DTS:X
+ DTS:HD
+ DTS:HD MA
+ TrueHD
+ DD
+ DD+
+ DTS
+ Direkteavspeling av Dolby Vision-profil 7
+ Ignorerer kompatibilitetssjekk på eininga
+ Oppdag
+ Be om
+ Ventar
+ Seerr-integrasjon
+ Fjern Seerr-tenar
+ Seerr-tenar lagt til
+ Hurtigkopling
+ Gi ei anna eining tilgang til å logge inn på kontoen din
+ Skriv inn hurtigkoplingskode
+ Koden må vere 6 siffer
+ Eininga fekk tilgang
+ Passord
+ Brukarnamn
+ URL
+ Populært no
+ Kommande filmar
+ Kommande TV-seriar
+ Be om i 4K
+ AV1-programvaredekoding
+ HDR-tekststil
+ Gjennomsiktigheit for bilettekst
+ Lysstyrke
+ Kontrast
+ Metning
+ Fargetone
+ Raud
+ Grøn
+ Blå
+ Sløring
+ Lagre til album
+ Spel lysbiletevising
+ Stopp lysbiletevising
+ Ved byrjinga
+ Ingen fleire bilete
+ Roter til venstre
+ Roter til høgre
+ Zoom inn
+ Zoom ut
+ Lengde på lysbiletevising
+ Spel videoar i lysbiletevisinga
+ Send logg med medieinfo til tenaren
+ Ingen grense
+ Maks dagar i Neste
+ Legg til rad
+ Sjangrar i %1$s
+ Nylig utgitt i %1$s
+ Høgde
+ Bruk på alle radene
+ Tilpass heim-sida
+ Rader på heim-sida
+ Last frå brukarprofil på tenaren
+ Lagre til brukarprofil på tenaren
+ Last frå nettklient
+ Bruk bilete av serien
+ Legg til rad for %1$s
+ Overskrive innstillingane på tenaren?
+ Overskrive lokale innstillingar?
+ For episodar
+ Forslag for %1$s
+ Auk storleiken på alle korta
+ Mink storleiken på alle korta
+ Bruk miniatyrbilete
+ Innstillingar lagra
+ Visingsval
+ Innebygde val for å raskt endre stilen på alle radene
+ Vel rader og bilete på heim-sida
+ Start etter
+ Animer
+ Maks aldersgrense
+ Ingen maksgrense
+ For alle aldrar
+ Opp til %s år
+ Skjermsparar
+ Innstillingar for skjermsparar
+ Start skjermsparar
+ Varigheit
+ Bilete
+ Inkluder typar
+ Kva typar element det skal visast bilete for
+ Tilpass
+ Beskjer
+ Fyll
+ Fyll breidde
+ Fyll høgde
+ Wholphin-standard
+ Wholphin-kompakt
+ Miniatyrbilete for seriar
+ Miniatyrbilete for episodar
+ Lågast
+ Låg
+ Middels
+ Høg
+ Full
+ Lilla
+ Oransje
+ Kraftig blå
+ Svart
+ Ignorer
+ Hopp over automatisk
+ Spør om å hoppe over
+ Berre bruk FFmpeg om det ikkje finst innebygd dekodar
+ Føretrekk FFmpeg framfor innebygde dekodarar
+ Aldri bruk FFmpeg-dekodarar
+ Ved slutten av avspelinga
+ Under rulletekst/outro
+ Kvit
+ Lysegrå
+ Mørkegrå
+ Gul
+ Cyan
+ Magenta
+ Omriss
+ Skugge
+ Omkransa
+ Rektangulær
+ Føretrekk MPV
+ Bruk ExoPlayer for HDR-avspeling
+ Plakat (2:3)
+ 16:9
+ 4:3
+ Kvadrat (1:1)
+ Primær
+ Miniatyr
+ Bilete med dynamisk farge
+ Berre bilete
+
+ API-nøkkel
+ Logg inn på Seerr-tenar
+ Jellyfin-brukar
+ Lokal brukar
+
+ Fann ingen eksterne undertekstar
+ No
+ Bruk skjermsparar i appen
+ Er du sikker på at du vil sletta dette elementet?
+ Vis alternativ for mediehandsaming
+ Skodespelar
+ Komponist
+ Forfattar
+ Gjestestjerne
+ Produsent
+ Dirigent
+ Tekstforfattar
+ Arrangør
+ Teknikar
+ Miksar
+ Skapar
+ Artist
+ Søk %s
+ Vel alle
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml
index 94da1f4d..16500357 100644
--- a/app/src/main/res/values-pt-rBR/strings.xml
+++ b/app/src/main/res/values-pt-rBR/strings.xml
@@ -64,9 +64,9 @@
Mais como isso
Mais
- - Filmes
-
-
+ - Filme
+ - Filmes
+ - Filmes
Nome
A Seguir
@@ -80,9 +80,9 @@
Finalização
Caminho
- - Pessoas
-
-
+ - Pessoa
+ - Pessoas
+ - Pessoas
Contagem de Reproduções
Reproduzir a partir daqui
@@ -151,18 +151,18 @@
Não assistidos com melhor avaliação
Trailer
- - Trailers
-
-
+ - Trailer
+ - Trailers
+ - Trailers
Agenda do DVR
Guia
Temporada
Temporadas
- - Programas de TV
-
-
+ - Programa de TV
+ - Programas de TV
+ - Programas de TV
Interface
Desconhecido
@@ -190,53 +190,53 @@
Extras
- Outro
-
-
+ - Outros
+ - Outros
- Por Trás das Cenas
-
-
+ - Por Trás das Cenas
+ - Por Trás das Cenas
- - Músicas Tema
-
-
+ - Música Tema
+ - Músicas Tema
+ - Músicas Tema
- - Vídeos Tema
-
-
+ - Vídeo Tema
+ - Vídeos Tema
+ - Vídeos Tema
- - Clipes
-
-
+ - Clipe
+ - Clipes
+ - Clipes
- - Cenas Deletadas
-
-
+ - Cena Deletada
+ - Cenas Deletadas
+ - Cenas Deletadas
- - Entrevistas
-
-
+ - Entrevista
+ - Entrevistas
+ - Entrevistas
- - Cenas
-
-
+ - Cena
+ - Cenas
+ - Cenas
- - Amostras
-
-
+ - Amostra
+ - Amostras
+ - Amostras
- - Curtas
-
-
+ - Curta
+ - Curtas
+ - Curtas
Favoritar %s
@@ -461,4 +461,124 @@
Altura
Aplicar a todas as linhas
Personalizar página inicial
+ Pular para trás ao resumir a reprodução
+ Pular para trás
+ Pular para frente
+ NAL
+ AVC
+ Salvar para álbum
+ Linhas iniciais
+ Carregar a partir do perfil de usuário no servidor
+ Salvar para o perfil de usuário no servidor
+ Carregar a partir do cliente web
+ Usar imagem da série
+ Adicionar linha para %1$s
+ Sobrescrever configurações no servidor?
+ Sobrescrever configurações locais?
+ Para episódios
+ Sugestões para %1$s
+ Aumentar tamanho para todos os cartões
+ Diminuir tamanho para todos os cartões
+ Usar imagens de prévia
+ Configurações salvas
+ Predefinições de exibição
+ Predefinições embutidas para estilizar rapidamente todas as linhas
+ Escolher linhas e imagens na página inicial
+ Iniciar após
+ Animar
+ Classificação etária máxima
+ Sem máximo
+ Para todas as idades
+ Até a idade %s
+ Protetor de tela
+ Configurações do protetor de tela
+ Iniciar o protetor de tela
+ Duração
+ Fotos
+ Incluir tipos
+ Para quais tipos de itens mostrar imagens
+ Ajustar
+ Cortar
+ Preencher
+ Preencher largura
+ Preencher altura
+ Padrão do Wholphin
+ Wholphin Compacto
+ Prévia das imagens das séries
+ Prévias das imagens do episódio
+ Mais baixo
+ Baixo
+ Médio
+ Alto
+ Máximo
+ Roxo
+ Laranja
+ Azul intenso
+ Preto
+ Ignorar
+ Pular automaticamente
+ Perguntar se pular
+ Apenas usar FFmpeg se não existir nenhum decodificador embarcado
+ Preferir usar FFmpeg aos decodificadores embarcados
+ Nunca usar decodificadores FFmpeg
+ Ao fim da reprodução
+ Durante os créditos/finalização
+ Branco
+ Cinza claro
+ Cinza escuro
+ Amarelo
+ Ciano
+ Magenta
+ Contorno
+ Sombra
+ Envolver
+ Encaixotar
+ Preferir MPV
+ Usar ExoPlayer para reprodução HDR
+ Pôster (2:3)
+ 16:9
+ 4:3
+ Quadrado (1:1)
+ Primária
+ Prévia
+ Imagem com cor dinâmica
+ Apenas imagem
+ Chave de API
+ Autenticar no servidor Seerr
+ Usuário Jellyfin
+ Usuário local
+ Nenhuma legenda remota encontrada
+ Presente
+ Usar protetor de tela do aplicativo
+ Tem certeza que quer apagar este item?
+ Mostrar opções de gerenciamento de mídia
+ Ator
+ Compositor
+ Escritor
+ Estrela convidada
+ Produtor
+ Regente
+ Letrista
+ Arranjador
+ Engenheiro
+ Criador
+ Artista
+ Buscar %s
+ Mixagem
+
+ - Bastidores
+
+
+
+
+ - %s minuto
+ - %s minutos
+ - %s minutos
+
+ Insira a URL e a chave de API
+ Procurar e baixar legendas
+ Baixar e atualizar
+ Procurar e baixar
+ Combinar \"Continue Assistindo\" e \"A Seguir\"
+ Elenco e equipe
diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml
index b4f44186..7e7fa408 100644
--- a/app/src/main/res/values-pt/strings.xml
+++ b/app/src/main/res/values-pt/strings.xml
@@ -566,4 +566,34 @@
Técnico de mixagem
Criador
Artista
+ Tem a certeza de que deseja eliminar este elemento?
+ Mostrar opções de gestão de multimédia
+ Pesquisar %s
+ Selecionar tudo
+ Álbuns
+ Artistas
+ Músicas
+ Ir para o artista
+ A reproduzir
+ Mistura instantânea
+ Ir para o álbum
+ Adicionar à fila
+ Artista do álbum
+ Álbum
+ Mais ouvidas
+ Reproduzir a seguir
+ Videoclips
+ Letras
+ Esconder letras
+ Mostrar letras
+ Música contêm letras
+ Remover da fila
+ Mostrar capa do álbum
+ Mostrar visualizador
+ Mostrar imagem de fundo
+ Reproduzir como?
+ Para visualizar o áudio que está a ser reproduzido, é necessária autorização para gravar áudio.
+ Continuar
+ Separar por tipos
+ Prefiro mostrar logótipos nos títulos
diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml
new file mode 100644
index 00000000..b99bf40b
--- /dev/null
+++ b/app/src/main/res/values-ro/strings.xml
@@ -0,0 +1,309 @@
+
+
+ Despre
+ Favorite
+ Adaugă server
+ Adaugă utilizator
+ Audio
+ Locul nașterii
+ Născut
+ Anulează înregistrarea
+ Anulează
+ Capitole
+ Alege %1$s
+ Colecție
+ Colecții
+ Se descarcă…
+ Regizat de %1$s
+ Golește memoria cache a imaginilor
+ Confirmă
+ Regizor
+ Favorite
+ Se încheie la %1$s
+ Introdu adresa IP sau URL-ul
+ Introdu adresa serverului
+ Episoade
+ Eroare la încărcarea colecției %1$s
+ Extern
+ Dimensiune
+ Activat
+ Șterge
+ Continuă vizionarea
+ %.2f secunde
+ Dezactivat
+ Genuri
+ Introducere
+ #ABCDEFGHIJKLMNOPQRSTUVWXYZ
+ Nume
+ În continuare
+ Nu există rezultate
+ Nu s-au găsit servere
+ Nu sunt înregistrări programate
+ Nu este disponibilă nicio actualizare
+
+ - Filme
+
+
+
+ Librărie
+ Mergi la
+ Mergi la seria
+ Se încarcă…
+ Marchez întreaga serie ca vizionată?
+ Marchez întreaga serie ca nevizionată?
+ Marchează ca nevizionat
+ Marchează ca vizionat
+
+ - Persoane
+
+
+
+ Număr de redări
+ Mai mult
+ Acasă
+ Nu există date
+ Încheiere
+ Cale
+ Mai multe articole similare
+ Ascunde
+ Limbă
+ Rezoluție
+ Titlu
+ Coloane
+ Confirmă PIN
+ Dolby Vision
+ Dolby Atmos
+ Introdu PIN
+ Conectare automată
+ PIN-ul trebuie sa aibă 4 sau mai multe caractere
+ Anulezi modificările?
+ An
+ Decadă
+ Filtru
+ Redat
+ Opțiuni MPV
+ Opțiuni ExoPlayer
+ Modifică mpv.conf
+ Spațiere
+ Rată de aspect
+ Înregistrare detaliată
+ Tip imagine
+
+ Vedete invitate
+ Nivel
+ Canale
+ Da
+ Nu
+ SDR
+ HDR
+ HDR10
+ HDR10+
+ HLG
+ Arată titlurile
+ Repetă
+ Afișează mai întâi canalele favorite
+ Sortează canalele după vizionarea recentă
+ Decalare subitrare
+ Descoperă
+ Redă trailer
+ Fără trailere
+ Integrare Seerr
+ Elimină serverul Seerr
+ Serverul Seer adăugat
+ Conectare rapidă
+ Autorizează un alt dispozitiv să se conecteze la contul tău
+ Introdu codul de conectare rapidă
+ Codul trebuie să aibă 6 cifre
+ Dispozitiv autorizat cu success
+ Parolă
+ Nume utilizator
+ Adresă URL
+ În tendințe
+ Filme viitoare
+ Seriale viitoare
+ Solicită în format 4K
+ Luminozitate
+ Contrast
+ Saturație
+ Nuanță
+ Roșu
+ Verde
+ Albastru
+ Estompare
+ Salvează pentru album
+ Albume
+ Artiști
+ Cântece
+ Mergi la artist
+ Se redă acum
+ Mix instant
+ Mergi la album
+ Adaugă în coadă
+ Adaugă rând
+ Genuri în %1$s
+ Lansate recent în %1$s
+ Înălțime
+ Aplică la toate rândurile
+ Personalizează pagina de pornire
+ Pentru episoade
+ Sugestii pentru %1$s
+ Mărește dimensiunea tuturor cardurilor
+ Micșorează dimensiunea tuturor cardurilor
+ Setări salvate
+ Durată
+ Fotografii
+ Cel mai scăzut
+ Scăzut
+ Mediu
+ Ridicat
+ Maxim
+ Violet
+ Portocaliu
+ Alb
+ Gri deschis
+ Gri închis
+ Galben
+ Cian
+ Magenta
+ Contur
+ Umbră
+ Preferă MPV
+ Folosește ExoPlayer pentru redare HDR
+ Pătrat (1:1)
+ Cheie API
+ Conectează-te la serverul Seerr
+ Servere descoperite
+
+ Reclamă
+ Evaluarea comunității
+ A apărut o eroare! Apasă butonul pentru a trimite jurnalele către serverul tău.
+ Implicit
+ Decedat
+ Înregistrări active
+ Evaluarea criticilor
+ Ascunde comenzile de redare
+ TV în direct
+ Redă
+ Recapitulare
+ Adăugat recent
+ Înregistrat recent
+ Lansat recent
+ Recomandat
+ Liste de redare
+ Previzualizare
+ Setări profil utilizator
+ Coadă
+ Adăugat recent în %1$s
+ Caută
+ Se caută…
+ Căutare vocală
+ Se procesează
+ Alege server
+ Alege utilizator
+ Sări peste
+ Reîncearcă
+ Arată
+ Studiouri
+ Trimite
+ Descărcarea durează mult; s-ar putea să fie necesar să repornești redarea
+ Subtitrare
+ Subtitrări
+ Sugestii
+ Schimbă serverele
+ Schimbă
+ Sezon
+ Sezoane
+
+ - Seriale TV
+
+
+
+ Interfață
+ Necunoscut
+ Actualizări
+ Versiune
+ Arată ceasul
+ %1$d ani vechime
+ Ghid
+ Pauză cu un click
+
+ - Interviuri
+
+
+
+ Fundal
+
+ - %s descărcare
+ - %s descărcări
+ - %s descărcări
+
+
+ - %d oră
+ - %d ore
+ - %d ore
+
+
+ - %s zi
+ - %s zile
+ - %s zile
+
+
+ - %d articol
+ - %d articole
+ - %d articole
+
+
+ - %d secundă
+ - %d secunde
+ - %d secunde
+
+
+ - %s minut
+ - %s minute
+ - %s minute
+
+ Verifică dacă sunt actualizări
+ Instalează actualizarea
+ Versiune instalată
+ Setări
+ Dimensiune font
+ Culoare font
+ Opacitate font
+ Stil margine
+ Culoare fundal
+ Resetare
+ Actualizare disponibilă
+ URL folosit pentru a căuta actualizări
+ URL actualizare
+ Trimite rapoarte de eroare
+ Stil subtitrare
+ Stil fundal
+ Sări peste introducere
+ Sări peste reclame
+ Sări peste previzualizare
+ Sări peste recapitulare
+ Incorect
+ Folosește utilizator/parolă
+ Solicitare
+ În așteptare
+ MPV este acum playerul implicit, cu excepția conținutului HDR.\nPoți modifica aceasta din setări.
+ Stilul subtitrărilor HDR
+ Rotește la dreapta
+ Pentru toate vârstele
+ Până la vârsta de %s
+ Economizor de ecran
+ Setări economizor de ecran
+ Sări automat
+ Cere să sari peste
+ Utilizator Jellyfin
+ Utilizator local
+
+ Compozitor
+ Scriitor
+ Versuri
+ Ascunde versurile
+ Afișează versurile
+ Cântecul are versuri
+ Selectează tot
+ Redă ca?
+
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index 04204a19..c69f25cb 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -393,4 +393,28 @@
Опорные кадры
Да
Нет
+
+ - %s день
+ - %s дня
+ - %s дней
+ - %s дней
+
+
+ - %s минута
+ - %s минуты
+ - %s минут
+ - %s минут
+
+ Каналы
+ Частота дискретизации
+ Контраст
+ Насыщенность
+ Оттенок
+ Красный
+ Зелёный
+ Синий
+ Яркость
+ Интеграция с Seerr
+ Найти %s
+ Выбрать всё
diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml
index dfb498ba..fb441889 100644
--- a/app/src/main/res/values-sk/strings.xml
+++ b/app/src/main/res/values-sk/strings.xml
@@ -1,6 +1,6 @@
- O Wholphine
+ O aplikácii
Aktívne nahrávky
Obľúbené
Pridať server
@@ -182,7 +182,7 @@
Pozadie
Úspech
Rodičovské hodnotenie
- Trvanie
+ Dĺžka
Bonusový materiál
- Ostatné
@@ -309,4 +309,289 @@
Pokus o odoslanie na posledný pripojený server
Odoslať správy o zlyhaní
Nastavenia
+
+ - %s minúta
+ - %s minúty
+ - %s minút
+ - %s minút
+
+ Oneskorenie pred prehrávaním nasledujúceho
+ Automatické prehrávanie nasledujúceho
+
+ Prepísanie prehrávania
+ Povoliť opätovné prehrávanie v nasledujúcich
+ Pri obnovení prehrávania preskočiť späť
+ Preskočiť späť
+ Správanie preskočenia reklamy
+ Preskočiť dopredu
+ Správanie preskočenia intra
+ Správanie preskočenia outra
+ Správanie preskočenia náhľadov
+ Správanie preskočenia rekapitulácie
+ Aktualizácia k dispozícii
+ URL používaná na kontrolu aktualizácií aplikácie
+ Aktualizovať URL
+ Veľkosť písma
+ Farba písma
+ Priehľadnosť písma
+ Štýl okraja
+ Farba okraja
+ Prehľadnosť pozadia
+ Štýl pozadia
+ Štýl titulkov
+ Farba pozadia
+ Reset
+ Tučné písmo
+ Backend prehrávania
+ MPV: Používa hardvérové dekódovanie
+ Zakážte, ak dochádza k pádom
+ Možnosti MPV
+ Možnosti ExoPlayer
+ Preskočiť segment
+ Preskočiť reklamy
+ Preskočiť náhľad
+ Preskočiť rekapituláciu
+ Preskočiť outro
+ Preskočiť intro
+ Prehrané
+ Filter
+ Rok
+ Desaťročie
+ Odstrániť
+ Dolby Vision
+ Dolby Atmos
+ MPV: Použiť gpu-next
+ Upraviť mpv.conf
+ Zrušiť zmeny?
+ Okraj
+ Podrobné logovanie
+ Zadajte PIN
+ Automatické prihlásenie
+ Prihlásenie cez server
+ Stlačte stredové tlačidlo na potvrdenie
+ Vyžadovať PIN pre profil
+ Potvrdiť PIN
+ Nesprávny
+ PIN musí mať minimálne 4 znaky
+ Odstráni PIN
+ Veľkosť pamäte obrázkov (MB)
+ Možnosti zobrazenia
+ Stĺpce
+ Rozostup
+ Pomer strán
+ Zobraziť podrobnosti
+ Typ obrázku
+
+ Hosťujúce hviezdy
+ Veľkosť okraja
+ Prepínanie obnovovacej frekvencie
+ Automaticky
+ Použite používateľské meno/heslo
+ Prihlásenie
+ Všeobecné
+ Kontajner
+ Titul
+ Kodek
+ Profil
+ Úroveň
+ Rozlíšenie
+ Anamorfný
+ Prekladané
+ Snímková frekvencia
+ Bitová hĺbka
+ Rozsah videa
+ Typ rozsahu videa
+ Farebný priestor
+ Prenos farieb
+ Základné farby
+ Formát pixelov
+ Referenčné snímky
+ NAL
+ Jazyk
+ Rozloženie
+ Kanály
+ Vzorkovacia frekvencia
+ AVC
+ Áno
+ Nie
+ SDR
+ HDR
+ HDR10
+ HDR10+
+ HLG
+ bit
+ Hz
+ Zobraziť názvy
+ Opakovať
+ Zobraziť obľúbené kanály ako prvé
+ Zoradiť kanály podľa nedávno pozretých
+ Farebné rozlíšenie programov
+ Oneskorenie titulkov
+ Štýl backdropu
+ Prepínanie rozlíšenia
+ Lokálne
+ Prehrať trailer
+ Žiadne trailery
+ Vyčistiť výber stôp
+ DTS:X
+ DTS:HD
+ TrueHD
+ DD
+ DD+
+ DTS
+ Priame prehrávanie Dolby Vision Profile 7
+ Ignorovať kontrolu kompatibility zariadenia
+ Žiadosť
+ Čaká
+ Integrácia Seerr
+ Odstrániť Seerr server
+ Seerr server pridaný
+ Rýchle pripojenie
+ Scenár
+ Hosťujúca hviezda
+ Producent
+ Dirigent
+ Textár
+ Aranžér
+ Inžinier
+ Zvukový mixér
+ Tvorca
+ Umelec
+ Hľadať %s
+ Zadajte kód Rýchleho pripojenia
+ Kód musí mať 6 číslic
+ Zariadenie bolo úspešne autorizované
+ Heslo
+ Používateľské meno
+ URL
+ Trendujúce
+ Nadchádzajúce filmy
+ Nadchádzajúce seriály
+ Vyžiadať v 4K
+ Softvérové dekódovanie AV1
+ MPV je teraz predvolený prehrávač okrem HDR.\nToto môžete zmeniť v nastaveniach.
+ Štýl HDR titulkov
+ Priehľadnosť obrázkových titulkov
+ Jas
+ Kontrast
+ Sýtosť
+ Odtieň
+ Červená
+ Zelená
+ Modrá
+ Rozmazanie
+ Uložiť do albumu
+ Prehrať prezentáciu
+ Zastaviť prezentáciu
+ Na začiatok
+ Žiadne ďalšie fotky
+ Otočiť doľava
+ Otočiť doprava
+ Zväčšiť
+ Zmenšiť
+ Trvanie prezentácie
+ Prehrávať videá počas prezentácie
+ Bez obmedzenia
+ DTS:HD MA
+ Prepísať nastavenia na serveri?
+ Prepísať lokálne nastavenia?
+ Pre epizódy
+ Návrhy pre %1$s
+ Zväčšiť veľkosť všetkých kariet
+ Zmenšiť veľkosť všetkých kariet
+ Použiť náhľadové obrázky
+ Nastavenia uložené
+ Predvoľby displeja
+ Vstavané predvoľby na rýchlu úpravu všetkých riadkov
+ Vyberte riadky a obrázky na domovskej stránke
+ Začať po
+ Animovať
+ Maximálne vekové hodnotenie
+ Bez vekového obmedzenia
+ Pre všetky vekové kategórie
+ Do veku %s
+ Šetrič obrazovky
+ Nastavenia šetriča obrazovky
+ Spustiť šetrič obrazovky
+ Trvanie
+ Fotografie
+ Zahrnúť typy
+ Pre ktoré typy položiek zobrazovať obrázky
+ Prispôsobiť
+ Orezať
+ Vyplniť
+ Vyplniť na šírku
+ Vyplniť na výšku
+ Wholphin predvolené
+ Wholphin kompaktné
+ Náhľadové obrázky seriálov
+ Náhľadové obrázky epizód
+ Náhľad
+ Pridať riadok
+ Žánre v %1$s
+ Nedávno vydané v %1$s
+ Výška
+ Použiť na všetky riadky
+ Prispôsobiť domovskú stránku
+ Riadky na domovskej stránke
+ Načítať zo serverového profilu používateľa
+ Uložiť do serverového profilu používateľa
+ Načítať z webového klienta
+ Použiť obrázok seriálu
+ Pridať riadok pre %1$s
+ Najnižšia
+ Nízka
+ Stredná
+ Vysoká
+ Plná
+ Fialová
+ Oranžová
+ Výrazná modrá
+ Čierna
+ Ignorovať
+ Preskočiť automaticky
+ Opýtať sa na preskočenie
+ Použiť FFmpeg len v prípade, ak neexistuje žiadny vstavaný dekodér
+ Preferovať použitie FFmpeg pred vstavanými dekodérmi
+ Nikdy nepoužívať FFmpeg dekodéry
+ Na konci prehrávania
+ Počas záverečných titulkov/outro
+ Biela
+ Svetlo šedá
+ Tmavo šedá
+ Žltá
+ Azúrová
+ Magenta
+ Obrys
+ Tieň
+ Prispôsobiť pozadie textu
+ Text v rámčeku
+ Uprednostniť MPV
+ Použiť ExoPlayer na prehrávanie HDR
+ Plagát (2:3)
+ 16:9
+ 4:3
+ Štvorec (1:1)
+ Primárny
+ Obrázok s dynamickou farbou
+ Iba obrázok
+
+ API kľúč
+ Prihlásenie na Seerr server
+ Jellyfin používateľ
+ Lokálny používateľ
+
+ Nenašli sa žiadne vzdialené titulky
+ Prebieha
+ Použiť šetrič obrazovky v aplikácii
+ Naozaj chcete odstrániť túto položku?
+ Zobraziť možnosti správy médií
+ Herec
+ Skladateľ
+ Vybrať všetko
+ Vyhľadať
+ Autorizujte iné zariadenie na prihlásenie do vášho účtu
+ Odoslať log informácií o médiách na server
+ Max. počet dní v sekcií Nasleduje
+ Albumy
diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml
index cef8bb90..c84a46c3 100644
--- a/app/src/main/res/values-sv/strings.xml
+++ b/app/src/main/res/values-sv/strings.xml
@@ -322,4 +322,109 @@
Ingen gräns
Lägg till rad
Tryck bakåt för att avbryta
+ Längd
+ Orange
+ Blå
+ Bild med dynamisk färg
+ Bakgrundsstil
+ Bakgrundsstil
+ Kantstorlek
+ Koden måste vara 6 siffror
+ Ange Quick Connect-kod
+ Enheten har auktoriserats
+ Auktorisera en annan enhet att logga in på ditt konto
+ Nyligen släppt i %1$s
+ Grön
+ Profil
+ Snabbanslutning
+ Ta bort Seerr-server
+ Begär i 4K
+ Begär
+ Kom ihåg vald flik
+ Växling av uppdateringsfrekvens
+ Referensbilder
+ Röd
+ Växling av uppdateringsfrekvens
+ Försök igen
+ Tillåt omvisning i “Nästa upp”
+ API nyckel
+ Anpassa navigationsmenyn
+ Välj vilka objekt som ska visas som standard, övriga döljs
+ Växla sidor i navigationsmenyn vid fokus
+ Klicka för att växla sida
+ Vid slutet av uppspelningen
+ Under eftertexter/slutsekvens
+ Pixelformat
+ PIN måste vara minst 4 tecken
+ Skriv över inställningar på servern?
+ Skriv över lokala inställningar?
+ Seerr-integration
+ Skicka apploggar till aktuell server
+ Markera alla
+ Seerr-server tillagd
+ Logga in på Seerr-server
+ Lokal användare
+ Jellyfin användare
+ Steg för sökreglage
+ Sök %s
+ Skicka medieinformationslogg till server
+ Försöker skicka till senast anslutna server
+ Pågående
+ Visa alternativ för mediahantering
+ Visa titlar
+ Fråga om att hoppa över
+ Hoppa över automatiskt
+ Användbart för felsökning
+
+
+ Mättnad
+ Spara till album
+ Spara till serverns användarprofil
+ Samplingsfrekvens
+ Hz
+ Serverfel
+ Inget tal upptäcktes
+ Kunde inte starta röstigenkänning
+ Röstigenkänning tog för lång tid
+ Beteende för att hoppa över reklam
+ Hoppa tillbaka
+ Hoppa tillbaka vid återupptagen uppspelning
+ Ignorera
+ Beteende för att hoppa över intro
+ Beteende för att hoppa över outro
+ Beteende för att hoppa över förhandsvisningar
+ Beteende för att hoppa över repris
+ URL
+ Utförlig loggning
+ Kommande filmer
+ Kommande TV-serier
+ Webbadress som används för att kontrollera appuppdateringar
+ MPV är nu standardspelaren förutom för HDR. Du kan ändra detta i inställningarna.
+ Använd seriebild
+ Använd miniatyrbilder
+ Rader på startsidan
+ Allmänt
+ Gästskådespelare
+ HDR-undertextstil
+ Höjd
+ Opacitet för bildundertexter
+ Nyans
+ Miniatyr
+ Primär
+ Dirigent
+ Container
+ Beskär
+ Fyll höjd
+ Anpassa
+ Fyll bredd
+ Fyll
+ Anpassa startsidan
+ Välj rader och bilder på startsidan
+ Är du säker på att du vill ta bort detta objekt?
+ Upptäck
+ Wholphin Kompakt
+ Wholphin Standard
+ Visningsförinställningar
+ Inbyggda förinställningar för att snabbt styla alla rader
+ Avsnittsminiatyrer
diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml
index 4e4c1838..ede526c0 100644
--- a/app/src/main/res/values-tr/strings.xml
+++ b/app/src/main/res/values-tr/strings.xml
@@ -256,7 +256,7 @@
Güncellemeleri kontrol et
Yalnızca diziler için geçerlidir
- ASS altyazıları için libass kullan
+ ASS altyazıları doğrudan oynat
PGS altyazıları doğrudan oynat
Her zaman Stereo\'ya dönüştür
FFmpeg kod çözücü kullan
@@ -560,4 +560,29 @@
Bu öğeyi silmek istediğinizden emin misiniz?
Medya yönetim seçeneklerini göster
Ara %s
+ Hepsinin seç
+ Albümler
+ Sanatçılar
+ Şarkılar
+ Sanatçıya git
+ Şimdi oynatılıyor
+ Anlık mix
+ Albüme git
+ Kuyruğa ekle
+ Albüm sanatçısı
+ Albüm
+ Popüler şarkılar
+ Sonrakini oynat
+ Müzik Videoları
+ Şarkı sözü
+ Şarkı sözünü gizle
+ Şarkı sözünü göster
+ Şarkının sözleri var mı
+ Kuyruktan sil
+ Albüm kapağını göster
+ Görselleştiriciyi göster
+ Arka Planı göster
+ Nasıl çalınsın?
+ Çalan sesi görselleştirmek için ses kaydetme izni gerekiyor.
+ Devam et
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index b78e3322..de4c04c7 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -83,7 +83,7 @@
播放列表
预览
用户配置设置
- 队列
+ 待播列表
前情提要
最近添加至 %1$s
最近添加
@@ -208,7 +208,7 @@
检查更新
仅适用于电视剧
- 使用 libass 处理 ASS 字幕
+ 直接播放 ASS 字幕
直接播放 PGS 字幕
始终降混为立体声
使用 FFmpeg 解码模块
@@ -533,4 +533,30 @@
显示媒体管理选项
是否确定要删除此项目?
搜索 %s
+ 全选
+ 专辑
+ 音乐人
+ 歌曲
+ 前往音乐人
+ 正在播放
+ 即时混音
+ 前往专辑
+ 添加到待播列表
+ 从待播列表中移除
+ 专辑音乐人
+ 专辑
+ 热门歌曲
+ 接下来播放
+ 音乐视频
+ 歌词
+ 隐藏歌词
+ 显示歌词
+ 歌曲有歌词
+ 显示专辑封面
+ 显示可视化效果
+ 显示背景幕
+ 播放方式?
+ 可视化当前播放的音频需要录音权限。
+ 继续
+ 分开类型
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index c96c987f..ecb15f67 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -25,7 +25,7 @@
播放清單
預覽
個人化設定
- 佇列
+ 待播佇列
最近新增
最近錄製
最近發行
@@ -533,4 +533,31 @@
確定要刪除此項目嗎?
顯示媒體管理選項
搜尋 %s
+ 全選
+ 專輯
+ 藝人
+ 歌曲
+ 前往藝人
+ 正在播放
+ 前往專輯
+ 加入待播佇列
+ 移出待播佇列
+ 專輯藝人
+ 專輯
+ 熱門歌曲
+ 下一首播放
+ 音樂影片
+ 歌詞
+ 隱藏歌詞
+ 顯示歌詞
+ 歌曲有歌詞
+ 顯示專輯封面
+ 顯示視覺化效果
+ 顯示背景
+ 播放方式?
+ 啟用音樂視覺化需要錄音權限。
+ 繼續
+ 即時混播
+ 依類型分開顯示
+ 優先顯示圖像標題
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 37c866dc..46501c70 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -748,6 +748,14 @@
Select all
Separate types
Prefer showing logos for titles
+ Wholphin updated to %s
+ Remove from continue watching
+ Remove series from next up
+ View series removed from next up
+ View more
+ Discover TV Shows
+ Discover Movies
+ Studios in %1$s
Direct play with libass
Direct play with ExoPlayer built-in
diff --git a/app/src/patches/play_store.patch b/app/src/patches/play_store.patch
deleted file mode 100644
index c1214f0e..00000000
--- a/app/src/patches/play_store.patch
+++ /dev/null
@@ -1,36 +0,0 @@
-# This patches Wholphin for releasing on app stores where self updating is not permitted
-
-diff --git i/app/src/main/AndroidManifest.xml w/app/src/main/AndroidManifest.xml
-index 4479ae86..80596698 100644
---- i/app/src/main/AndroidManifest.xml
-+++ w/app/src/main/AndroidManifest.xml
-@@ -6,7 +6,6 @@
-
-
-
--
-
-@@ -20,7 +19,7 @@
- android:required="false" />
-
-+ android:required="true" />
-
-diff --git i/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt w/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt
-index 9e0665dd..bc1e1c16 100644
---- i/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt
-+++ w/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt
-@@ -65,7 +65,7 @@ class UpdateChecker
-
- private val NOTE_REGEX = Regex("")
-
-- val ACTIVE = true
-+ val ACTIVE = false
- }
-
- /**
diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpServiceTests.kt b/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpServiceTests.kt
new file mode 100644
index 00000000..4c605df4
--- /dev/null
+++ b/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpServiceTests.kt
@@ -0,0 +1,187 @@
+@file:UseSerializers(
+ UUIDSerializer::class,
+ DateTimeSerializer::class,
+)
+
+package com.github.damontecres.wholphin.services
+
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import kotlinx.serialization.UseSerializers
+import kotlinx.serialization.json.Json
+import org.jellyfin.sdk.api.client.ApiClient
+import org.jellyfin.sdk.api.client.Response
+import org.jellyfin.sdk.api.client.extensions.tvShowsApi
+import org.jellyfin.sdk.api.operations.TvShowsApi
+import org.jellyfin.sdk.model.api.BaseItemDto
+import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
+import org.jellyfin.sdk.model.api.DisplayPreferencesDto
+import org.jellyfin.sdk.model.api.ScrollDirection
+import org.jellyfin.sdk.model.api.SortOrder
+import org.jellyfin.sdk.model.api.UserItemDataDto
+import org.jellyfin.sdk.model.api.request.GetNextUpRequest
+import org.jellyfin.sdk.model.serializer.DateTimeSerializer
+import org.jellyfin.sdk.model.serializer.UUIDSerializer
+import org.junit.After
+import org.junit.Assert
+import org.junit.Before
+import org.junit.Test
+import java.time.LocalDateTime
+import java.util.UUID
+
+class LatestNextUpServiceTests {
+ private val testDispatcher = StandardTestDispatcher()
+
+ private val userId = UUID.randomUUID()
+ private val seriesId1 = UUID.randomUUID()
+ private val seriesId2 = UUID.randomUUID()
+ private val seriesId3 = UUID.randomUUID()
+
+ private val mockApi = mockk(relaxed = true)
+ private val mockTvShowsApi = mockk()
+ private val mockDatePlayedService = mockk()
+ private val mockDisplayPreferencesService = mockk()
+ private val mockFavoriteWatchManager = mockk(relaxed = true)
+
+ private val latestNextUpService =
+ LatestNextUpService(mockApi, mockDatePlayedService, mockDisplayPreferencesService, mockFavoriteWatchManager)
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+ every { mockApi.tvShowsApi } returns mockTvShowsApi
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun `Test nothing is filtered out`() =
+ runTest {
+ coEvery { mockTvShowsApi.getNextUp(any() as GetNextUpRequest) } returns mockResponse
+ coEvery { mockDisplayPreferencesService.getDisplayPreferences(any(), any(), any()) } returns
+ buildRemoved()
+ val result = latestNextUpService.getNextUp(userId, 20, false, false, 0)
+ Assert.assertEquals(3, result.size)
+ val seriesIds = result.map { it.data.seriesId }
+ Assert.assertTrue(seriesIds.containsAll(listOf(seriesId1, seriesId2, seriesId3)))
+ }
+
+ @Test
+ fun `Test seriesId1 is filtered out`() =
+ runTest {
+ coEvery { mockTvShowsApi.getNextUp(any() as GetNextUpRequest) } returns mockResponse
+ coEvery { mockDisplayPreferencesService.getDisplayPreferences(any()) } returns
+ buildRemoved(seriesId1 to LocalDateTime.now().minusDays(1))
+ val result = latestNextUpService.getNextUp(userId, 20, false, false, 0)
+ Assert.assertEquals(2, result.size)
+ val seriesIds = result.map { it.data.seriesId }
+ Assert.assertTrue(seriesId1 !in seriesIds)
+ Assert.assertTrue(seriesIds.containsAll(listOf(seriesId2, seriesId3)))
+ }
+
+ @Test
+ fun `Test seriesId2 is filtered out`() =
+ runTest {
+ coEvery { mockTvShowsApi.getNextUp(any() as GetNextUpRequest) } returns mockResponse
+ coEvery { mockDisplayPreferencesService.getDisplayPreferences(any()) } returns
+ buildRemoved(seriesId2 to LocalDateTime.now().minusDays(1))
+ val result = latestNextUpService.getNextUp(userId, 20, false, false, 0)
+ Assert.assertEquals(2, result.size)
+ val seriesIds = result.map { it.data.seriesId }
+ Assert.assertTrue(seriesId2 !in seriesIds)
+ Assert.assertTrue(seriesIds.containsAll(listOf(seriesId1, seriesId3)))
+ }
+
+ @Test
+ fun `Test seriesId1 and seriesId2 are filtered out`() =
+ runTest {
+ coEvery { mockTvShowsApi.getNextUp(any() as GetNextUpRequest) } returns mockResponse
+ coEvery { mockDisplayPreferencesService.getDisplayPreferences(any()) } returns
+ buildRemoved(
+ seriesId1 to LocalDateTime.now().minusDays(1),
+ seriesId2 to LocalDateTime.now().minusDays(1),
+ )
+ val result = latestNextUpService.getNextUp(userId, 20, false, false, 0)
+ Assert.assertEquals(1, result.size)
+ val seriesIds = result.map { it.data.seriesId }
+ Assert.assertTrue(seriesId1 !in seriesIds)
+ Assert.assertTrue(seriesId2 !in seriesIds)
+ Assert.assertTrue(seriesIds.containsAll(listOf(seriesId3)))
+ }
+
+ fun buildRemoved(vararg values: Pair): DisplayPreferencesDto =
+ testDisplayPreferencesDto.copy(
+ customPrefs =
+ mutableMapOf().apply {
+ val str = Json.encodeToString(RemovedSeriesIds(values.toMap()))
+ put(LatestNextUpService.REMOVED_KEY, str)
+ },
+ )
+
+ private val testUserItemDataDto =
+ UserItemDataDto(
+ playbackPositionTicks = 0L,
+ playCount = 0,
+ isFavorite = false,
+ lastPlayedDate = null,
+ played = false,
+ key = "",
+ itemId = UUID.randomUUID(),
+ )
+
+ private val mockResponse: Response =
+ Response(
+ content =
+ BaseItemDtoQueryResult(
+ items =
+ listOf(
+ mockk(relaxed = true) {
+ every { seriesId } returns seriesId1
+ every { userData } returns testUserItemDataDto.copy(lastPlayedDate = LocalDateTime.now().minusDays(7))
+ },
+ mockk(relaxed = true) {
+ every { seriesId } returns seriesId2
+ every { userData } returns testUserItemDataDto.copy(lastPlayedDate = null)
+ },
+ mockk(relaxed = true) {
+ every { seriesId } returns seriesId3
+ every { userData } returns testUserItemDataDto.copy(lastPlayedDate = LocalDateTime.now().plusDays(7))
+ },
+ ),
+ totalRecordCount = 3,
+ startIndex = 0,
+ ),
+ status = 200,
+ headers = mapOf(),
+ )
+}
+
+val testDisplayPreferencesDto =
+ DisplayPreferencesDto(
+ id = "default",
+ viewType = null,
+ sortBy = null,
+ indexBy = null,
+ rememberIndexing = false,
+ primaryImageHeight = 0,
+ primaryImageWidth = 0,
+ customPrefs = mapOf(),
+ scrollDirection = ScrollDirection.VERTICAL,
+ showBackdrop = false,
+ rememberSorting = false,
+ sortOrder = SortOrder.ASCENDING,
+ showSidebar = false,
+ client = null,
+ )
diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/NextUpTest.kt b/app/src/test/java/com/github/damontecres/wholphin/test/NextUpTest.kt
index 0e9bb92e..c5c2ffee 100644
--- a/app/src/test/java/com/github/damontecres/wholphin/test/NextUpTest.kt
+++ b/app/src/test/java/com/github/damontecres/wholphin/test/NextUpTest.kt
@@ -1,13 +1,15 @@
package com.github.damontecres.wholphin.test
-import android.content.Context
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.updateHomePagePreferences
import com.github.damontecres.wholphin.services.DatePlayedService
+import com.github.damontecres.wholphin.services.DisplayPreferencesService
+import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.LatestNextUpService
import com.github.damontecres.wholphin.services.mockQueryResult
+import com.github.damontecres.wholphin.services.testDisplayPreferencesDto
import io.mockk.CapturingSlot
import io.mockk.coEvery
import io.mockk.every
@@ -33,15 +35,28 @@ class NextUpTest {
private val mockTvShowsApi = mockk()
private val mockApi = mockk(relaxed = true)
- private val mockContext = mockk()
private val mockDatePlayedService = mockk()
+ private val mockDisplayPreferencesService = mockk()
+ private val mockFavoriteWatchManager = mockk(relaxed = true)
private val latestNextUpService =
- LatestNextUpService(mockContext, mockApi, mockDatePlayedService)
+ LatestNextUpService(
+ mockApi,
+ mockDatePlayedService,
+ mockDisplayPreferencesService,
+ mockFavoriteWatchManager,
+ )
@Before
fun setUp() {
every { mockApi.tvShowsApi } returns mockTvShowsApi
+ coEvery {
+ mockDisplayPreferencesService.getDisplayPreferences(
+ any(),
+ any(),
+ any(),
+ )
+ } returns testDisplayPreferencesDto
}
@Test
diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt
index 84aa72b6..e8c2fed1 100644
--- a/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt
+++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt
@@ -43,6 +43,7 @@ class TestHomeRowSamples {
parentId = UUID.randomUUID(),
viewOptions = HomeRowViewOptions(),
),
+ HomeRowConfig.Studios(parentId = UUID.randomUUID()),
HomeRowConfig.ContinueWatching(
viewOptions = HomeRowViewOptions(),
),
@@ -98,6 +99,7 @@ class TestHomeRowSamples {
is HomeRowConfig.TvPrograms -> foundTypes.add(it::class)
is HomeRowConfig.Suggestions -> foundTypes.add(it::class)
is HomeRowConfig.TvChannels -> foundTypes.add(it::class)
+ is HomeRowConfig.Studios -> foundTypes.add(it::class)
}
}
Assert.assertEquals(HomeRowConfig::class.sealedSubclasses.size, foundTypes.size)
@@ -129,6 +131,7 @@ class TestHomeRowSamples {
latestNextUpService = mockk(),
imageUrlService = mockk(),
suggestionService = mockk(),
+ displayPreferencesService = mockk(),
)
val str = """{
diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt
index 66178a4e..25121739 100644
--- a/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt
+++ b/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt
@@ -1,5 +1,7 @@
package com.github.damontecres.wholphin.ui
+import android.app.Application
+import android.content.pm.ActivityInfo
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
@@ -14,6 +16,7 @@ import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.requestFocus
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.test.core.app.ApplicationProvider
import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.ScreensaverState
import com.github.damontecres.wholphin.services.SetupDestination
@@ -49,8 +52,11 @@ import org.junit.Assert
import org.junit.Before
import org.junit.Rule
import org.junit.Test
+import org.junit.rules.TestWatcher
+import org.junit.runner.Description
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import javax.inject.Inject
@@ -61,7 +67,23 @@ class BasicUiTests {
@get:Rule(order = 0)
var hiltRule = HiltAndroidRule(this)
+ // From https://github.com/robolectric/robolectric/pull/4736#issuecomment-1831034882
@get:Rule(order = 1)
+ val addActivityToRobolectricRule =
+ object : TestWatcher() {
+ override fun starting(description: Description?) {
+ super.starting(description)
+ val appContext: Application = ApplicationProvider.getApplicationContext()
+ val activityInfo =
+ ActivityInfo().apply {
+ name = TestActivity::class.java.name
+ packageName = appContext.packageName
+ }
+ shadowOf(appContext.packageManager).addOrUpdateActivity(activityInfo)
+ }
+ }
+
+ @get:Rule(order = 2)
val composeTestRule = createAndroidComposeRule()
@Inject
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 55c85740..f46d9654 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -45,8 +45,12 @@ kotlinxCoroutinesTest = "1.10.2"
coreTesting = "2.2.0"
openapi-generator = "7.21.0"
runner = "1.7.0"
+wholphin-extensions = "0.1.0"
[libraries]
+wholphin-extensions-mpv = { module = "com.github.damontecres.wholphin.mpv:wholphin-mpv", version.ref = "wholphin-extensions" }
+wholphin-extensions-ffmpeg = { module = "com.github.damontecres.wholphin.media3:decoder-ffmpeg", version.ref = "wholphin-extensions" }
+wholphin-extensions-av1 = { module = "com.github.damontecres.wholphin.media3:decoder-av1", version.ref = "wholphin-extensions" }
aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" }
aboutlibraries-compose-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "aboutLibraries" }
acra-dialog = { module = "ch.acra:acra-dialog", version.ref = "acra" }
diff --git a/scripts/ffmpeg/README.md b/scripts/ffmpeg/README.md
deleted file mode 100644
index 36a9ca9a..00000000
--- a/scripts/ffmpeg/README.md
+++ /dev/null
@@ -1,14 +0,0 @@
-# FFmpeg ExoPlayer decoder extension module
-
-Builds the ffmpeg decoder extension module for `ExoPlayer` which supports extra codecs during playback.
-
-## Usage
-
-```sh
-./build_ffmpeg_decoder.sh /path/to/android/ndk
-```
-
-Or clean build:
-```sh
-./build_ffmpeg_decoder.sh /path/to/android/ndk --clean
-```
diff --git a/scripts/ffmpeg/build_ffmpeg_decoder.sh b/scripts/ffmpeg/build_ffmpeg_decoder.sh
deleted file mode 100755
index 338b850e..00000000
--- a/scripts/ffmpeg/build_ffmpeg_decoder.sh
+++ /dev/null
@@ -1,97 +0,0 @@
-#!/usr/bin/env bash
-set -ex
-
-if [ -z "$1" ]; then
- echo "Error: Must provide NDK path"
- exit 1
-fi
-NDK_PATH="$1"
-
-SCRIPT_PATH="$(realpath "${BASH_SOURCE[0]}")"
-SCRIPT_DIR="$(dirname "${SCRIPT_PATH}")"
-PROJECT_ROOT="$(realpath "${SCRIPT_DIR}/../../")"
-
-# Config
-ANDROID_ABI=21
-ENABLED_DECODERS=(dca ac3 eac3 mlp truehd flac alac pcm_mulaw pcm_alaw mp3)
-FFMPEG_BRANCH="release/6.0"
-DAV1D_BRANCH="1.5.3"
-
-# Path configs
-DIR_PATH="$(pwd)"
-TARGET_PATH="$PROJECT_ROOT/app/libs"
-MEDIA_PATH="$DIR_PATH/ffmpeg_decoder/media"
-FFMPEG_MODULE_PATH="$MEDIA_PATH/libraries/decoder_ffmpeg/src/main"
-FFMPEG_PATH="$DIR_PATH/ffmpeg_decoder/ffmpeg"
-AV1_MODULE_PATH="$MEDIA_PATH/libraries/decoder_av1/src/main"
-HOST="$(uname -s | tr '[:upper:]' '[:lower:]')"
-HOST_PLATFORM="$HOST-x86_64"
-
-if [[ "$2" == "--clean" ]]; then
- rm -rf ffmpeg_decoder
- rm -f "$TARGET_PATH/lib-decoder-ffmpeg-release.aar"
- rm -f "$TARGET_PATH/lib-decoder-av1-release.aar"
-fi
-
-mkdir -p "$TARGET_PATH"
-mkdir -p ffmpeg_decoder
-
-echo "$PROJECT_ROOT/gradle/libs.versions.toml"
-
-media_version="$(grep "androidx-media3 = " "$PROJECT_ROOT/gradle/libs.versions.toml" | awk -F'"' '{print $2}')"
-
-pushd ffmpeg_decoder || exit
-
-if [[ -d media ]]; then
- pushd media || exit
- git fetch origin "$media_version" --depth 1
- git checkout --force FETCH_HEAD
-else
- git clone https://github.com/androidx/media.git --depth 1 --single-branch -b "$media_version" media
-fi
-
-if [[ -d ffmpeg ]]; then
- pushd ffmpeg || exit
- git fetch origin "$FFMPEG_BRANCH" --depth 1
- git checkout --force FETCH_HEAD
-else
- git clone https://github.com/FFmpeg/FFmpeg --depth 1 --single-branch -b "$FFMPEG_BRANCH" ffmpeg
-fi
-
-[[ ! -d "${FFMPEG_MODULE_PATH}/jni/ffmpeg" ]] && ln -s "$FFMPEG_PATH" "${FFMPEG_MODULE_PATH}/jni/ffmpeg"
-
-pushd "${FFMPEG_MODULE_PATH}/jni" || exit
-
-./build_ffmpeg.sh "${FFMPEG_MODULE_PATH}" "${NDK_PATH}" "${HOST_PLATFORM}" "${ANDROID_ABI}" "${ENABLED_DECODERS[@]}"
-
-# av1 module
-
-pushd "$AV1_MODULE_PATH/jni" || exit
-
-if [[ ! -d cpu_features ]]; then
- git clone https://github.com/google/cpu_features --depth 1 --single-branch cpu_features
-fi
-
-pushd "$AV1_MODULE_PATH/jni" || exit
-
-if [[ -d dav1d ]]; then
- pushd dav1d || exit
- git fetch origin "$DAV1D_BRANCH" --depth 1
- git checkout --force FETCH_HEAD
-else
- git clone https://code.videolan.org/videolan/dav1d --depth 1 --single-branch -b "$DAV1D_BRANCH" dav1d
-fi
-
-pushd "$AV1_MODULE_PATH/jni" || exit
-
-/usr/bin/env bash ./build_dav1d.sh "${AV1_MODULE_PATH}" "${NDK_PATH}" "${HOST_PLATFORM}"
-
-
-pushd "$MEDIA_PATH" || exit
-./gradlew :lib-decoder-ffmpeg:assemble :lib-decoder-av1:assemble
-popd || exit
-
-popd || exit
-cp "$MEDIA_PATH/libraries/decoder_ffmpeg/buildout/outputs/aar/lib-decoder-ffmpeg-release.aar" "$TARGET_PATH/"
-cp "$MEDIA_PATH/libraries/decoder_av1/buildout/outputs/aar/lib-decoder-av1-release.aar" "$TARGET_PATH/"
-popd || exit
diff --git a/scripts/mpv/README.md b/scripts/mpv/README.md
deleted file mode 100644
index 106907bb..00000000
--- a/scripts/mpv/README.md
+++ /dev/null
@@ -1,22 +0,0 @@
-# MPV build scripts
-
-This scripts are adapted from https://github.com/mpv-android/mpv-android/tree/ae0d956c5a98ab8bf25af7e2c73bcb59e19c15b7/buildscripts licensed MIT.
-
-## Instructions
-
-```bash
-cd scripts/mpv
-./get_dependencies.sh
-
-# Install build dependencies
-pip install meson jsonschema
-
-export NDK_PATH=... # Such as ~/Library/Android/sdk/ndk/29.0.14206865
-# Build arm64
-PATH="$PATH:$NDK_PATH/toolchains/llvm/prebuilt/darwin-x86_64/bin" ./buildall.sh --clean --arch arm64 mpv
-# Build arm32
-PATH="$PATH:$NDK_PATH/toolchains/llvm/prebuilt/darwin-x86_64/bin" ./buildall.sh mpv
-
-cd ../..
-env PREFIX32="$(realpath scripts/mpv/prefix/armv7l)" PREFIX64="$(realpath scripts/mpv/prefix/arm64)" "$NDK_PATH/ndk-build" -C app/src/main -j && cp -fr app/src/main/libs/ app/src/main/jnilibs/
-```
diff --git a/scripts/mpv/buildall.sh b/scripts/mpv/buildall.sh
deleted file mode 100755
index 56208015..00000000
--- a/scripts/mpv/buildall.sh
+++ /dev/null
@@ -1,180 +0,0 @@
-#!/bin/bash -e
-
-cd "$( dirname "${BASH_SOURCE[0]}" )"
-. ./include/depinfo.sh
-
-cleanbuild=0
-nodeps=0
-clang=1
-target=mpv-android
-arch=armv7l
-
-getdeps () {
- varname="dep_${1//-/_}[*]"
- echo ${!varname}
-}
-
-loadarch () {
- unset CC CXX CPATH LIBRARY_PATH C_INCLUDE_PATH CPLUS_INCLUDE_PATH
- unset CFLAGS CXXFLAGS CPPFLAGS LDFLAGS
-
- local apilvl=21
- # ndk_triple: what the toolchain actually is
- # cc_triple: what Google pretends the toolchain is
- if [ "$1" == "armv7l" ]; then
- export ndk_suffix=
- export ndk_triple=arm-linux-androideabi
- cc_triple=armv7a-linux-androideabi$apilvl
- prefix_name=armv7l
- elif [ "$1" == "arm64" ]; then
- export ndk_suffix=-arm64
- export ndk_triple=aarch64-linux-android
- cc_triple=$ndk_triple$apilvl
- prefix_name=arm64
- elif [ "$1" == "x86" ]; then
- export ndk_suffix=-x86
- export ndk_triple=i686-linux-android
- cc_triple=$ndk_triple$apilvl
- prefix_name=x86
- elif [ "$1" == "x86_64" ]; then
- export ndk_suffix=-x64
- export ndk_triple=x86_64-linux-android
- cc_triple=$ndk_triple$apilvl
- prefix_name=x86_64
- else
- echo "Invalid architecture" >&2
- exit 1
- fi
- export prefix_dir="$PWD/prefix/$prefix_name"
- if [ $clang -eq 1 ]; then
- export CC=$cc_triple-clang
- export CXX=$cc_triple-clang++
- else
- export CC=$cc_triple-gcc
- export CXX=$cc_triple-g++
- fi
- export LDFLAGS="-Wl,-O1,--icf=safe -Wl,-z,max-page-size=16384"
- export AR=llvm-ar
- export RANLIB=llvm-ranlib
-}
-
-setup_prefix () {
- if [ ! -d "$prefix_dir" ]; then
- mkdir -p "$prefix_dir"
- # enforce flat structure (/usr/local -> /)
- ln -s . "$prefix_dir/usr"
- ln -s . "$prefix_dir/local"
- fi
-
- local cpu_family=${ndk_triple%%-*}
- [ "$cpu_family" == "i686" ] && cpu_family=x86
-
- if ! command -v pkg-config >/dev/null; then
- echo "pkg-config not provided!"
- return 1
- fi
-
- # meson wants to be spoonfed this file, so create it ahead of time
- # also define: release build, static libs and no source downloads at runtime(!!!)
- cat >"$prefix_dir/crossfile.tmp" <&2 '\e[1;31m%s\e[m\n' "Target $1 not found"
- return 1
- fi
- if [ $nodeps -eq 0 ]; then
- printf >&2 '\e[1;34m%s\e[m\n' "Preparing $1..."
- local deps=$(getdeps $1)
- echo >&2 "Dependencies: $deps"
- for dep in $deps; do
- build $dep
- done
- fi
- printf >&2 '\e[1;34m%s\e[m\n' "Building $1..."
- if [ "$1" == "mpv-android" ]; then
- pushd ..
- BUILDSCRIPT=buildscripts/scripts/$1.sh
- else
- pushd deps/$1
- BUILDSCRIPT=../../scripts/$1.sh
- fi
- [ $cleanbuild -eq 1 ] && $BUILDSCRIPT clean
- $BUILDSCRIPT build
- popd
-}
-
-usage () {
- printf '%s\n' \
- "Usage: buildall.sh [options] [target]" \
- "Builds the specified target (default: $target)" \
- "-n Do not build dependencies" \
- "--clean Clean build dirs before compiling" \
- "--gcc Use gcc compiler (unsupported!)" \
- "--arch Build for specified architecture (default: $arch; supported: armv7l, arm64, x86, x86_64)"
- exit 0
-}
-
-while [ $# -gt 0 ]; do
- case "$1" in
- --clean)
- cleanbuild=1
- ;;
- -n|--no-deps)
- nodeps=1
- ;;
- --gcc)
- clang=0
- ;;
- --arch)
- shift
- arch=$1
- ;;
- -h|--help)
- usage
- ;;
- -*)
- echo "Unknown flag $1" >&2
- exit 1
- ;;
- *)
- target=$1
- ;;
- esac
- shift
-done
-
-loadarch $arch
-setup_prefix
-build $target
-
-[ "$target" == "mpv-android" ] && \
- ls -lh ../app/build/outputs/apk/{default,api29}/*/*.apk
-
-exit 0
diff --git a/scripts/mpv/get_dependencies.sh b/scripts/mpv/get_dependencies.sh
deleted file mode 100755
index 829b2a06..00000000
--- a/scripts/mpv/get_dependencies.sh
+++ /dev/null
@@ -1,75 +0,0 @@
-#!/usr/bin/env bash
-
-set -exou pipefail
-
-BUILD_PATH="./deps"
-
-mkdir -p "$BUILD_PATH"
-pushd "$BUILD_PATH" || exit
-
-function clone(){
- repo=$1
- branch=$2
- dir=$3
- shift 3
-
- if [[ -d "$dir" ]]; then
- pushd "$dir" || exit
- git fetch origin "$branch" --depth 1
- git checkout --force FETCH_HEAD
- popd || exit
- else
- git clone "$repo" --depth 1 --single-branch -b "$branch" "$dir" "$@"
- fi
-}
-
-clone "https://github.com/videolan/dav1d" "1.5.3" dav1d
-
-clone "https://github.com/FFmpeg/FFmpeg" "n8.0" ffmpeg
-
-clone "https://gitlab.freedesktop.org/freetype/freetype.git" "VER-2-14-1" freetype2 --recurse-submodules
-
-clone "https://github.com/libass/libass" "0.17.4" libass
-
-rm -rf libplacebo
-git clone "https://github.com/haasn/libplacebo" --single-branch -b "master" --recurse-submodules libplacebo
-pushd libplacebo || exit
-git checkout 1bd8d2d6a715bc870bdffa6759e62c419000dd51
-popd || exit
-
-clone "https://github.com/mpv-player/mpv" "v0.41.0" mpv
-
-if [[ ! -d mbedtls ]]; then
- mkdir mbedtls
- wget https://github.com/Mbed-TLS/mbedtls/releases/download/mbedtls-3.6.4/mbedtls-3.6.4.tar.bz2 -O - | \
- tar -xj -C mbedtls --strip-components=1
-fi
-
-if [[ ! -d fribidi ]]; then
- mkdir fribidi
- wget https://github.com/fribidi/fribidi/releases/download/v1.0.16/fribidi-1.0.16.tar.xz -O - | \
- tar -xJ -C fribidi --strip-components=1
-fi
-
-if [[ ! -d harfbuzz ]]; then
- mkdir harfbuzz
- wget https://github.com/harfbuzz/harfbuzz/releases/download/12.1.0/harfbuzz-12.1.0.tar.xz -O - | \
- tar -xJ -C harfbuzz --strip-components=1
-fi
-
-version_unibreak="6.1"
-if [[ ! -d unibreak ]]; then
- mkdir unibreak
- wget https://github.com/adah1972/libunibreak/releases/download/libunibreak_${version_unibreak//./_}/libunibreak-${version_unibreak}.tar.gz -O - | \
- tar -xz -C unibreak --strip-components=1
-fi
-
-if [[ ! -d lua ]]; then
- mkdir lua
- wget https://www.lua.org/ftp/lua-5.2.4.tar.gz -O - | \
- tar -xz -C lua --strip-components=1
-fi
-
-# python packages: jsonschema jinja2 meson
-
-popd || exit
diff --git a/scripts/mpv/include/ci.sh b/scripts/mpv/include/ci.sh
deleted file mode 100755
index 19b44a0b..00000000
--- a/scripts/mpv/include/ci.sh
+++ /dev/null
@@ -1,91 +0,0 @@
-#!/bin/bash -e
-
-# go to buildscripts root folder
-cd "$( dirname "${BASH_SOURCE[0]}" )/.."
-
-. ./include/depinfo.sh
-
-msg() {
- printf '==> %s\n' "$1"
-}
-
-fetch_prefix() {
- if [[ "$CACHE_MODE" == folder ]]; then
- local text=
- if [ -f "$CACHE_FOLDER/id.txt" ]; then
- text=$(cat "$CACHE_FOLDER/id.txt")
- else
- echo "Cache seems to be empty"
- fi
- printf 'Expecting "%s",\nfound "%s".\n' "$ci_tarball" "$text"
- if [[ "$text" == "$ci_tarball" ]]; then
- tar -xzf "$CACHE_FOLDER/data.tgz" -C prefix && return 0
- fi
- fi
- return 1
-}
-
-build_prefix() {
- msg "Building the prefix ($ci_tarball)..."
-
- msg "Fetching deps"
- IN_CI=1 ./include/download-deps.sh
-
- # build everything mpv depends on (but not mpv itself)
- for x in ${dep_mpv[@]}; do
- msg "Building $x"
- ./buildall.sh $x
- done
-
- if [[ "$CACHE_MODE" == folder && -w "$CACHE_FOLDER" ]]; then
- msg "Compressing the prefix"
- tar -cvzf "$CACHE_FOLDER/data.tgz" -C prefix .
- echo "$ci_tarball" >"$CACHE_FOLDER/id.txt"
- fi
-}
-
-export WGET="wget --progress=bar:force"
-
-if [ "$1" = "export" ]; then
- # export variable with unique cache identifier
- echo "CACHE_IDENTIFIER=$ci_tarball"
- exit 0
-elif [ "$1" = "install" ]; then
- # install deps
- if [[ -n "$ANDROID_HOME" && -d "$ANDROID_HOME" ]]; then
- msg "Linking existing SDK"
- mkdir -p sdk
- ln -sv "$ANDROID_HOME" sdk/android-sdk-linux
- fi
-
- msg "Fetching SDK + NDK"
- IN_CI=1 ./include/download-sdk.sh
-
- msg "Fetching mpv"
- mkdir -p deps/mpv
- $WGET https://github.com/mpv-player/mpv/archive/master.tar.gz -O master.tgz
- tar -xzf master.tgz -C deps/mpv --strip-components=1
- rm master.tgz
-
- msg "Trying to fetch existing prefix"
- mkdir -p prefix
- fetch_prefix || build_prefix
- exit 0
-elif [ "$1" = "build" ]; then
- # run build
- :
-else
- exit 1
-fi
-
-msg "Building mpv"
-./buildall.sh -n mpv || {
- # show logfile if configure failed
- [ ! -f deps/mpv/_build/config.h ] && cat deps/mpv/_build/meson-logs/meson-log.txt
- exit 1
-}
-
-msg "Building mpv-android"
-./buildall.sh -n
-
-exit 0
diff --git a/scripts/mpv/include/depinfo.sh b/scripts/mpv/include/depinfo.sh
deleted file mode 100755
index 875e3687..00000000
--- a/scripts/mpv/include/depinfo.sh
+++ /dev/null
@@ -1,43 +0,0 @@
-#!/bin/bash -e
-
-## Dependency versions
-# Make sure to keep v_ndk and v_ndk_n in sync, both are listed on the NDK download page
-
-v_sdk=11076708_latest
-v_ndk=r29
-v_ndk_n=29.0.14206865
-v_sdk_platform=35
-v_sdk_build_tools=35.0.0
-
-v_lua=5.2.4
-v_unibreak=6.1
-v_harfbuzz=12.1.0
-v_fribidi=1.0.16
-v_freetype=2.14.1
-v_mbedtls=3.6.4
-
-
-## Dependency tree
-# I would've used a dict but putting arrays in a dict is not a thing
-
-dep_mbedtls=()
-dep_dav1d=()
-dep_ffmpeg=(mbedtls dav1d)
-dep_freetype2=()
-dep_fribidi=()
-dep_harfbuzz=()
-dep_unibreak=()
-dep_libass=(freetype2 fribidi harfbuzz unibreak)
-dep_lua=()
-dep_libplacebo=()
-dep_mpv=(ffmpeg libass lua libplacebo)
-dep_mpv_android=(mpv)
-
-
-## for CI workflow
-
-# pinned ffmpeg revision
-v_ci_ffmpeg=n8.0
-
-# filename used to uniquely identify a build prefix
-ci_tarball="prefix-ndk-${v_ndk}-lua-${v_lua}-unibreak-${v_unibreak}-harfbuzz-${v_harfbuzz}-fribidi-${v_fribidi}-freetype-${v_freetype}-mbedtls-${v_mbedtls}-ffmpeg-${v_ci_ffmpeg}.tgz"
diff --git a/scripts/mpv/include/path.sh b/scripts/mpv/include/path.sh
deleted file mode 100755
index 4ab1073c..00000000
--- a/scripts/mpv/include/path.sh
+++ /dev/null
@@ -1,32 +0,0 @@
-#!/bin/bash
-
-DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && cd .. && pwd )"
-
-. "$DIR/include/depinfo.sh"
-
-os=linux
-[[ "$OSTYPE" == "darwin"* ]] && os=mac
-export os
-
-if [ "$os" == "mac" ]; then
- [ -z "$cores" ] && cores=$(sysctl -n hw.ncpu)
- # various things rely on GNU behaviour
- export INSTALL=`which ginstall`
- export SED=gsed
-else
- [ -z "$cores" ] && cores=$(grep -c ^processor /proc/cpuinfo)
-fi
-cores=${cores:-4}
-
-# configure pkg-config paths if inside buildscripts
-if [ -n "$ndk_triple" ]; then
- export PKG_CONFIG_SYSROOT_DIR="$prefix_dir"
- export PKG_CONFIG_LIBDIR="$PKG_CONFIG_SYSROOT_DIR/lib/pkgconfig"
- unset PKG_CONFIG_PATH
-fi
-
-toolchain=$(echo "$DIR/sdk/android-ndk-${v_ndk}/toolchains/llvm/prebuilt/"*)
-[ -d "$toolchain" ] && \
- export PATH="$toolchain/bin:$DIR/sdk/android-ndk-${v_ndk}:$DIR/sdk/bin:$PATH"
-export ANDROID_HOME="$DIR/sdk/android-sdk-$os"
-unset ANDROID_SDK_ROOT ANDROID_NDK_ROOT
diff --git a/scripts/mpv/scripts/dav1d.sh b/scripts/mpv/scripts/dav1d.sh
deleted file mode 100755
index aef89de7..00000000
--- a/scripts/mpv/scripts/dav1d.sh
+++ /dev/null
@@ -1,22 +0,0 @@
-#!/bin/bash -e
-
-. ../../include/path.sh
-
-build=_build$ndk_suffix
-
-if [ "$1" == "build" ]; then
- true
-elif [ "$1" == "clean" ]; then
- rm -rf $build
- exit 0
-else
- exit 255
-fi
-
-unset CC CXX # meson wants these unset
-
-meson setup $build --cross-file "$prefix_dir"/crossfile.txt \
- -Denable_tests=false -Db_lto=true -Dstack_alignment=16
-
-ninja -C $build -j$cores
-DESTDIR="$prefix_dir" ninja -C $build install
diff --git a/scripts/mpv/scripts/ffmpeg.sh b/scripts/mpv/scripts/ffmpeg.sh
deleted file mode 100755
index c9c73a74..00000000
--- a/scripts/mpv/scripts/ffmpeg.sh
+++ /dev/null
@@ -1,48 +0,0 @@
-#!/bin/bash -e
-
-. ../../include/path.sh
-
-if [ "$1" == "build" ]; then
- true
-elif [ "$1" == "clean" ]; then
- rm -rf _build$ndk_suffix
- exit 0
-else
- exit 255
-fi
-
-mkdir -p _build$ndk_suffix
-cd _build$ndk_suffix
-
-cpu=armv7-a
-[[ "$ndk_triple" == "aarch64"* ]] && cpu=armv8-a
-[[ "$ndk_triple" == "x86_64"* ]] && cpu=generic
-[[ "$ndk_triple" == "i686"* ]] && cpu="i686 --disable-asm"
-
-cpuflags=
-[[ "$ndk_triple" == "arm"* ]] && cpuflags="$cpuflags -mfpu=neon -mcpu=cortex-a8"
-
-args=(
- --target-os=android --enable-cross-compile
- --cross-prefix=$ndk_triple- --cc=$CC --pkg-config=pkg-config --nm=llvm-nm
- --arch=${ndk_triple%%-*} --cpu=$cpu
- --extra-cflags="-I$prefix_dir/include $cpuflags" --extra-ldflags="-L$prefix_dir/lib"
-
- --enable-{jni,mediacodec,mbedtls,libdav1d} --disable-vulkan
- --disable-static --enable-shared --enable-{gpl,version3}
-
- # disable unneeded parts
- --disable-{stripping,doc,programs}
- # to keep the build lean we disable some feature quite aggressively:
- # - muxers, encoders: mpv-android does not have any way to use these
- # - devices: no practical use on Android
- --disable-{muxers,encoders,devices}
- # useful to taking screenshots
- --enable-encoder=mjpeg,png
- # useful for the `dump-cache` command
- --enable-muxer=mov,matroska,mpegts
-)
-../configure "${args[@]}"
-
-make -j$cores
-make DESTDIR="$prefix_dir" install
diff --git a/scripts/mpv/scripts/freetype2.sh b/scripts/mpv/scripts/freetype2.sh
deleted file mode 100755
index deceb0b4..00000000
--- a/scripts/mpv/scripts/freetype2.sh
+++ /dev/null
@@ -1,21 +0,0 @@
-#!/bin/bash -e
-
-. ../../include/path.sh
-
-build=_build$ndk_suffix
-
-if [ "$1" == "build" ]; then
- true
-elif [ "$1" == "clean" ]; then
- rm -rf $build
- exit 0
-else
- exit 255
-fi
-
-unset CC CXX # meson wants these unset
-
-meson setup $build --cross-file "$prefix_dir"/crossfile.txt
-
-ninja -C $build -j$cores
-DESTDIR="$prefix_dir" ninja -C $build install
diff --git a/scripts/mpv/scripts/fribidi.sh b/scripts/mpv/scripts/fribidi.sh
deleted file mode 100755
index a9e22aa5..00000000
--- a/scripts/mpv/scripts/fribidi.sh
+++ /dev/null
@@ -1,22 +0,0 @@
-#!/bin/bash -e
-
-. ../../include/path.sh
-
-build=_build$ndk_suffix
-
-if [ "$1" == "build" ]; then
- true
-elif [ "$1" == "clean" ]; then
- rm -rf $build
- exit 0
-else
- exit 255
-fi
-
-unset CC CXX # meson wants these unset
-
-meson setup $build --cross-file "$prefix_dir"/crossfile.txt \
- -D{tests,docs}=false
-
-ninja -C $build -j$cores
-DESTDIR="$prefix_dir" ninja -C $build install
diff --git a/scripts/mpv/scripts/harfbuzz.sh b/scripts/mpv/scripts/harfbuzz.sh
deleted file mode 100755
index c1d074f6..00000000
--- a/scripts/mpv/scripts/harfbuzz.sh
+++ /dev/null
@@ -1,22 +0,0 @@
-#!/bin/bash -e
-
-. ../../include/path.sh
-
-build=_build$ndk_suffix
-
-if [ "$1" == "build" ]; then
- true
-elif [ "$1" == "clean" ]; then
- rm -rf $build
- exit 0
-else
- exit 255
-fi
-
-unset CC CXX # meson wants these unset
-
-meson setup $build --cross-file "$prefix_dir"/crossfile.txt \
- -Dtests=disabled -Ddocs=disabled
-
-ninja -C $build -j$cores
-DESTDIR="$prefix_dir" ninja -C $build install
diff --git a/scripts/mpv/scripts/libass.sh b/scripts/mpv/scripts/libass.sh
deleted file mode 100755
index 739916de..00000000
--- a/scripts/mpv/scripts/libass.sh
+++ /dev/null
@@ -1,25 +0,0 @@
-#!/bin/bash -e
-
-. ../../include/path.sh
-
-if [ "$1" == "build" ]; then
- true
-elif [ "$1" == "clean" ]; then
- rm -rf _build$ndk_suffix
- exit 0
-else
- exit 255
-fi
-
-[ -f configure ] || ./autogen.sh
-
-mkdir -p _build$ndk_suffix
-cd _build$ndk_suffix
-
-../configure \
- --host=$ndk_triple --with-pic \
- --enable-static --disable-shared \
- --enable-libunibreak --disable-require-system-font-provider
-
-make -j$cores
-make DESTDIR="$prefix_dir" install
diff --git a/scripts/mpv/scripts/libplacebo.sh b/scripts/mpv/scripts/libplacebo.sh
deleted file mode 100755
index fac33d94..00000000
--- a/scripts/mpv/scripts/libplacebo.sh
+++ /dev/null
@@ -1,25 +0,0 @@
-#!/bin/bash -e
-
-. ../../include/path.sh
-
-build=_build$ndk_suffix
-
-if [ "$1" == "build" ]; then
- true
-elif [ "$1" == "clean" ]; then
- rm -rf $build
- exit 0
-else
- exit 255
-fi
-
-unset CC CXX
-meson setup $build --cross-file "$prefix_dir"/crossfile.txt \
- -Dvulkan=disabled -Ddemos=false
-
-ninja -C $build -j$cores
-DESTDIR="$prefix_dir" ninja -C $build install
-
-# add missing library for static linking
-# this isn't "-lstdc++" due to a meson bug: https://github.com/mesonbuild/meson/issues/11300
-${SED:-sed} '/^Libs:/ s|$| -lc++|' "$prefix_dir/lib/pkgconfig/libplacebo.pc" -i
diff --git a/scripts/mpv/scripts/lua.sh b/scripts/mpv/scripts/lua.sh
deleted file mode 100755
index cc3c52ec..00000000
--- a/scripts/mpv/scripts/lua.sh
+++ /dev/null
@@ -1,44 +0,0 @@
-#!/bin/bash -e
-
-. ../../include/path.sh
-
-if [ "$1" == "build" ]; then
- true
-elif [ "$1" == "clean" ]; then
- make clean
- exit 0
-else
- exit 255
-fi
-
-# Building seperately from source tree is not supported, this means we are forced to always clean
-$0 clean
-
-mycflags=(
- # ensures correct linking into libmpv.so
- -fPIC
- # bionic is missing decimal_point in localeconv [src/llex.c]
- -Dgetlocaledecpoint\\\(\\\)=\\\(46\\\)
- # force fallback as ftello/fseeko are not defined [src/liolib.c]
- -Dlua_fseek
-)
-
-# LUA_T= and LUAC_T= to disable building lua & luac
-# -Dgetlocaledecpoint()=('.') fixes bionic missing decimal_point in localeconv
-make CC="$CC" AR="$AR rc" RANLIB="$RANLIB" \
- MYCFLAGS="${mycflags[*]}" \
- PLAT=linux LUA_T= LUAC_T= -j$cores
-
-# TO_BIN=/dev/null disables installing lua & luac
-make INSTALL=${INSTALL:-install} INSTALL_TOP="$prefix_dir" TO_BIN=/dev/null install
-
-# make pc only generates a partial pkg-config file because ????
-mkdir -p $prefix_dir/lib/pkgconfig
-make pc >$prefix_dir/lib/pkgconfig/lua.pc
-cat >>$prefix_dir/lib/pkgconfig/lua.pc <<'EOF'
-Name: Lua
-Description:
-Version: ${version}
-Libs: -L${libdir} -llua
-Cflags: -I${includedir}
-EOF
diff --git a/scripts/mpv/scripts/mbedtls.sh b/scripts/mpv/scripts/mbedtls.sh
deleted file mode 100755
index 32ae32f9..00000000
--- a/scripts/mpv/scripts/mbedtls.sh
+++ /dev/null
@@ -1,22 +0,0 @@
-#!/bin/bash -e
-
-. ../../include/path.sh
-
-if [ "$1" == "build" ]; then
- true
-elif [ "$1" == "clean" ]; then
- make clean
- exit 0
-else
- exit 255
-fi
-
-$0 clean # separate building not supported, always clean
-if [[ "$ndk_triple" == "i686"* ]]; then
- ./scripts/config.py unset MBEDTLS_AESNI_C
-else
- ./scripts/config.py set MBEDTLS_AESNI_C
-fi
-
-make -j$cores no_test
-make DESTDIR="$prefix_dir" install
diff --git a/scripts/mpv/scripts/mpv-android.sh b/scripts/mpv/scripts/mpv-android.sh
deleted file mode 100755
index af152810..00000000
--- a/scripts/mpv/scripts/mpv-android.sh
+++ /dev/null
@@ -1,77 +0,0 @@
-#!/bin/bash -e
-
-DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
-BUILD="$DIR/.."
-MPV_ANDROID="$DIR/../.."
-
-. $BUILD/include/path.sh
-. $BUILD/include/depinfo.sh
-
-if [ "$1" == "build" ]; then
- true
-elif [ "$1" == "clean" ]; then
- rm -rf $MPV_ANDROID/{app,.}/build $MPV_ANDROID/app/src/main/{libs,obj}
- exit 0
-else
- exit 255
-fi
-
-[ -n "$ANDROID_SIGNING_KEY" ] && BUNDLE=1
-
-nativeprefix () {
- if [ -f $BUILD/prefix/$1/lib/libmpv.so ]; then
- echo $BUILD/prefix/$1
- else
- echo >&2 "Warning: libmpv.so not found in native prefix for $1, support will be omitted"
- fi
-}
-
-prefix32=$(nativeprefix "armv7l")
-prefix64=$(nativeprefix "arm64")
-prefix_x64=$(nativeprefix "x86_64")
-prefix_x86=$(nativeprefix "x86")
-
-if [[ -z "$prefix32" && -z "$prefix64" && -z "$prefix_x64" && -z "$prefix_x86" ]]; then
- echo >&2 "Error: no mpv library detected."
- exit 255
-fi
-
-PREFIX32=$prefix32 PREFIX64=$prefix64 PREFIX_X64=$prefix_x64 PREFIX_X86=$prefix_x86 \
-ndk-build -C app/src/main -j$cores
-
-targets=(assembleDebug)
-if [ -z "$DONT_BUILD_RELEASE" ]; then
- targets+=(assembleRelease)
- [ -n "$BUNDLE" ] && targets+=(bundleRelease)
-fi
-./gradlew "${targets[@]}"
-
-if [ -n "$ANDROID_SIGNING_KEY" ]; then
- cd "${MPV_ANDROID}/app/build/outputs/apk"
- apksigner=${ANDROID_HOME}/build-tools/${v_sdk_build_tools}/apksigner
- for v in default api29; do
- pushd $v
- # sign the universal debug APK
- "$apksigner" sign --ks "${ANDROID_SIGNING_KEY}" \
- --in debug/app-$v-universal-debug.apk --out debug/app-$v-universal-debug-signed.apk
- # but all of the release APKs
- for apk in release/*-unsigned.apk; do
- "$apksigner" sign --ks "${ANDROID_SIGNING_KEY}" \
- --in $apk --out ${apk/-unsigned/-signed}
- done
- popd
- done
- # and the bundle
- cd ../bundle
- if [ -n "$BUNDLE" ]; then
- if [ -z "$ANDROID_SIGNING_ALIAS" ]; then
- echo >&2 "Error: ANDROID_SIGNING_ALIAS must be set to use jarsigner"
- exit 1
- fi
- pushd defaultRelease
- jarsigner -keystore "${ANDROID_SIGNING_KEY}" -signedjar \
- app-default-release-signed.aab app-default-release.aab \
- "${ANDROID_SIGNING_ALIAS}"
- popd
- fi
-fi
diff --git a/scripts/mpv/scripts/mpv.sh b/scripts/mpv/scripts/mpv.sh
deleted file mode 100755
index feb85c95..00000000
--- a/scripts/mpv/scripts/mpv.sh
+++ /dev/null
@@ -1,30 +0,0 @@
-#!/bin/bash -e
-
-. ../../include/path.sh
-
-build=_build$ndk_suffix
-
-if [ "$1" == "build" ]; then
- true
-elif [ "$1" == "clean" ]; then
- rm -rf $build
- exit 0
-else
- exit 255
-fi
-
-unset CC CXX # meson wants these unset
-
-meson setup $build --cross-file "$prefix_dir"/crossfile.txt \
- --default-library shared \
- -Diconv=disabled -Dlua=enabled \
- -Dlibmpv=true -Dcplayer=false \
- -Dmanpage-build=disabled
-
-ninja -C $build -j$cores
-if [ -f $build/libmpv.a ]; then
- echo >&2 "Meson fucked up, forcing rebuild."
- $0 clean
- exec $0 build
-fi
-DESTDIR="$prefix_dir" ninja -C $build install
diff --git a/scripts/mpv/scripts/unibreak.sh b/scripts/mpv/scripts/unibreak.sh
deleted file mode 100755
index d18dbea5..00000000
--- a/scripts/mpv/scripts/unibreak.sh
+++ /dev/null
@@ -1,24 +0,0 @@
-#!/bin/bash -e
-
-. ../../include/path.sh
-
-build=_build$ndk_suffix
-
-if [ "$1" == "build" ]; then
- true
-elif [ "$1" == "clean" ]; then
- rm -rf $build
- exit 0
-else
- exit 255
-fi
-
-mkdir -p $build
-cd $build
-
-../configure \
- --host=$ndk_triple --with-pic \
- --enable-static --disable-shared
-
-make -j$cores
-make DESTDIR="$prefix_dir" install
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 4b95efe8..a9195399 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -16,6 +16,14 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
+ @Suppress("ktlint:standard:property-naming")
+ val WholphinExtensionsUsername: String? by settings
+ if (!WholphinExtensionsUsername.isNullOrBlank()) {
+ maven("https://maven.pkg.github.com/damontecres/wholphin-extensions") {
+ name = "WholphinExtensions"
+ credentials(PasswordCredentials::class)
+ }
+ }
}
}