From 1c7d6acd9be422fb8c7678cbe27eee6f6f8f7d42 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:01:48 +0530 Subject: [PATCH] Revert "Merge branch 'desktopweb' into cmp-rewrite" This reverts commit b420dcaf71ba8930adaa69b6a2cd28fed83bbc97, reversing changes made to fdadc7c610013cc78cd37395e62809ed0591c07c. --- .../Configuration/DesktopVersion.properties | 2 - composeApp/build.gradle.kts | 461 --- .../core/build/AppFeaturePolicy.android.kt | 2 - .../core/build/AppVersionPolicy.android.kt | 7 - .../plugins/PluginPlatform.android.kt | 2 - .../kotlin/com/nuvio/app/Platform.android.kt | 3 +- .../app/core/ui/NuvioAsyncImage.android.kt | 50 - .../app/core/ui/SecondaryClick.android.kt | 5 - .../features/player/PlayerEngine.android.kt | 6 - .../settings/ThemeSettingsStorage.android.kt | 14 - .../core/build/AppFeaturePolicy.android.kt | 2 - .../core/build/AppVersionPolicy.android.kt | 7 - .../composeResources/values/strings.xml | 5 - .../commonMain/kotlin/com/nuvio/app/App.kt | 605 +--- .../kotlin/com/nuvio/app/Platform.kt | 3 +- .../nuvio/app/core/build/AppFeaturePolicy.kt | 2 - .../nuvio/app/core/build/AppVersionPolicy.kt | 7 - .../app/core/sync/ProfileSettingsSync.kt | 191 +- .../com/nuvio/app/core/ui/NuvioAsyncImage.kt | 36 - .../ui/NuvioContinueWatchingActionSheet.kt | 3 +- .../nuvio/app/core/ui/NuvioFloatingPrompt.kt | 3 +- .../app/core/ui/NuvioPosterActionSheet.kt | 3 +- .../nuvio/app/core/ui/NuvioShelfComponents.kt | 58 +- .../com/nuvio/app/core/ui/SecondaryClick.kt | 5 - .../nuvio/app/features/addons/AddonsScreen.kt | 2 +- .../app/features/catalog/CatalogScreen.kt | 2 +- .../features/collection/FolderDetailScreen.kt | 2 +- .../app/features/details/MetaDetailsScreen.kt | 4 +- .../features/details/PersonDetailScreen.kt | 2 +- .../details/TmdbEntityBrowseScreen.kt | 2 +- .../details/components/DetailActionButtons.kt | 5 +- .../details/components/DetailCastSection.kt | 2 +- .../components/DetailFloatingHeader.kt | 2 +- .../features/details/components/DetailHero.kt | 26 +- .../components/DetailProductionSection.kt | 2 +- .../details/components/DetailSeriesContent.kt | 16 +- .../components/DetailTrailersSection.kt | 2 +- .../com/nuvio/app/features/home/HomeScreen.kt | 33 +- .../components/HomeContinueWatchingSection.kt | 6 +- .../home/components/HomeHeroSection.kt | 540 +--- .../home/components/HomeSkeletonLoading.kt | 6 +- .../app/features/library/LibraryScreen.kt | 3 - .../nuvio/app/features/player/PlayerEngine.kt | 233 -- .../features/player/PlayerEpisodesPanel.kt | 2 +- .../app/features/player/PlayerOverlays.kt | 2 +- .../player/PlayerScreenRuntimeEffects.kt | 5 - .../PlayerScreenRuntimeGestureActions.kt | 40 +- .../PlayerScreenRuntimeSourceActions.kt | 62 +- .../player/PlayerScreenRuntimeState.kt | 15 +- .../features/player/PlayerScreenRuntimeUi.kt | 967 +----- .../player/PlayerStreamsRepository.kt | 56 +- .../features/player/skip/NextEpisodeCard.kt | 2 +- .../features/profiles/ProfileEditScreen.kt | 2 +- .../features/profiles/ProfileRepository.kt | 14 +- .../profiles/ProfileSelectionScreen.kt | 5 +- .../features/profiles/ProfileSwitcherTab.kt | 363 +-- .../features/search/SearchDiscoverContent.kt | 2 +- .../nuvio/app/features/search/SearchScreen.kt | 3 - .../settings/AppearanceSettingsPage.kt | 90 - .../settings/DesktopNavigationLayout.kt | 21 - .../settings/LicensesAttributionsPage.kt | 2 +- .../app/features/settings/SettingsRootPage.kt | 57 +- .../app/features/settings/SettingsScreen.kt | 63 +- .../app/features/settings/SettingsSearch.kt | 74 +- .../settings/SupportersContributorsPage.kt | 2 +- .../settings/ThemeSettingsRepository.kt | 14 - .../features/settings/ThemeSettingsStorage.kt | 2 - .../app/features/streams/StreamBadgeChip.kt | 2 +- .../app/features/streams/StreamsScreen.kt | 35 +- .../features/streams/StreamsTabletLayout.kt | 2 +- .../features/trakt/TraktScrobbleRepository.kt | 6 +- .../features/watchprogress/AirDateUtils.kt | 39 +- .../desktopMain/kotlin/com/nuvio/app/Main.kt | 94 - .../kotlin/com/nuvio/app/Platform.desktop.kt | 10 - .../app/core/auth/AuthStorage.desktop.kt | 18 - .../core/build/AppFeaturePolicy.desktop.kt | 4 +- .../core/build/AppVersionPolicy.desktop.kt | 7 - .../nuvio/app/core/storage/DesktopStorage.kt | 148 - ...PlatformLocalAccountDataCleaner.desktop.kt | 7 - .../core/sync/AppForegroundMonitor.desktop.kt | 8 - .../app/core/ui/AppIconPainter.desktop.kt | 25 - .../app/core/ui/NativeTabBridge.desktop.kt | 18 - .../app/core/ui/NuvioAsyncImage.desktop.kt | 281 -- .../core/ui/NuvioPlatformInsets.desktop.kt | 13 - .../core/ui/PlatformBackHandler.desktop.kt | 9 - .../app/core/ui/PlatformExitApp.desktop.kt | 7 - .../core/ui/PlatformImageLoader.desktop.kt | 5 - .../core/ui/PosterCardStyleStorage.desktop.kt | 15 - .../app/core/ui/SecondaryClick.desktop.kt | 24 - .../features/addons/AddonPlatform.desktop.kt | 108 - ...CollectionMobileSettingsStorage.desktop.kt | 15 - .../collection/CollectionStorage.desktop.kt | 15 - .../debrid/DebridSettingsStorage.desktop.kt | 138 - .../MetaScreenSettingsStorage.desktop.kt | 15 - .../details/SeasonViewModeStorage.desktop.kt | 15 - .../HeroTrailerPlayerSurface.desktop.kt | 21 - .../downloads/DownloadsClock.desktop.kt | 5 - .../DownloadsLiveStatusPlatform.desktop.kt | 5 - .../DownloadsPlatformDownloader.desktop.kt | 193 -- .../downloads/DownloadsStorage.desktop.kt | 15 - .../HomeCatalogSettingsStorage.desktop.kt | 15 - .../CollectionCardRemoteImage.desktop.kt | 34 - .../features/library/LibraryClock.desktop.kt | 5 - .../library/LibraryStorage.desktop.kt | 14 - .../mdblist/MdbListSettingsStorage.desktop.kt | 84 - ...sodeReleaseNotificationPlatform.desktop.kt | 15 - ...pisodeReleaseNotificationsClock.desktop.kt | 15 - ...sodeReleaseNotificationsStorage.desktop.kt | 15 - .../p2p/P2pSettingsStorage.desktop.kt | 21 - .../p2p/P2pStreamingEngine.desktop.kt | 24 - .../ExternalPlayerLauncherEffect.desktop.kt | 15 - .../player/ExternalPlayerPlatform.desktop.kt | 68 - .../features/player/PlayerEngine.desktop.kt | 243 -- .../PlayerLanguagePreferences.desktop.kt | 12 - .../player/PlayerPlatformEffects.desktop.kt | 19 - .../player/PlayerSettingsStorage.desktop.kt | 406 --- .../PlayerTrackPreferenceStorage.desktop.kt | 94 - .../player/desktop/DesktopAppFullscreen.kt | 60 - .../features/player/desktop/DesktopHostOs.kt | 22 - .../desktop/DesktopPlayerLaunchShield.kt | 87 - .../player/desktop/DesktopWindowChrome.kt | 21 - .../player/desktop/MacosAwtViewResolver.kt | 82 - .../player/desktop/NativePlayerBridge.kt | 286 -- .../player/desktop/NativePlayerController.kt | 953 ------ .../player/desktop/NativePlayerHost.kt | 53 - .../player/skip/DateComponents.desktop.kt | 12 - .../features/plugins/PluginCrypto.desktop.kt | 59 - .../plugins/PluginPlatform.desktop.kt | 35 - .../profiles/AvatarStorage.desktop.kt | 13 - .../ProfileHoverHapticFeedback.desktop.kt | 7 - .../ProfilePinCacheStorage.desktop.kt | 20 - .../profiles/ProfilePinCrypto.desktop.kt | 11 - .../profiles/ProfileStorage.desktop.kt | 13 - .../search/SearchHistoryStorage.desktop.kt | 15 - .../IntegrationLogoPainter.desktop.kt | 21 - .../settings/PluginsSettingsPage.desktop.kt | 14 - .../settings/ThemeSettingsStorage.desktop.kt | 83 - .../streams/BingeGroupCacheStorage.desktop.kt | 19 - .../app/features/streams/EpochMs.desktop.kt | 3 - .../StreamBadgeSettingsStorage.desktop.kt | 53 - .../streams/StreamLinkCacheStorage.desktop.kt | 19 - .../tmdb/TmdbSettingsStorage.desktop.kt | 114 - .../TrailerPlaybackResolver.desktop.kt | 5 - .../trakt/TraktBrandPainter.desktop.kt | 17 - .../trakt/TraktCommentsStorage.desktop.kt | 30 - .../trakt/TraktPlatformClock.desktop.kt | 18 - .../features/trakt/TraktStorage.desktop.kt | 36 - .../features/watched/WatchedClock.desktop.kt | 5 - .../watched/WatchedStorage.desktop.kt | 14 - ...ntinueWatchingEnrichmentStorage.desktop.kt | 21 - ...tinueWatchingPreferencesStorage.desktop.kt | 15 - .../CurrentDateProvider.desktop.kt | 7 - .../ResumePromptStorage.desktop.kt | 21 - .../WatchProgressClock.desktop.kt | 5 - .../WatchProgressStorage.desktop.kt | 14 - .../desktopMain/native/macos/player_bridge.mm | 2663 ----------------- .../native/windows/player_bridge.cpp | 1965 ------------ .../resources/icons/nuvio-app-icon.icns | Bin 209987 -> 0 bytes .../resources/icons/nuvio-app-icon.ico | Bin 115246 -> 0 bytes .../resources/icons/nuvio-app-icon.png | Bin 73029 -> 0 bytes .../resources/player-ui/controls.css | 2144 ------------- .../resources/player-ui/controls.html | 380 --- .../resources/player-ui/controls.js | 2196 -------------- .../plugins/PluginRuntimeDesktopTest.kt | 34 - .../app/features/plugins/PluginRepository.kt | 6 +- .../app/core/build/AppFeaturePolicy.ios.kt | 2 - .../app/core/build/AppVersionPolicy.ios.kt | 7 - .../app/core/build/AppFeaturePolicy.ios.kt | 2 - .../app/core/build/AppVersionPolicy.ios.kt | 7 - .../features/plugins/PluginPlatform.ios.kt | 2 - .../kotlin/com/nuvio/app/Platform.ios.kt | 3 +- .../nuvio/app/core/ui/NuvioAsyncImage.ios.kt | 50 - .../nuvio/app/core/ui/SecondaryClick.ios.kt | 5 - .../app/features/player/PlayerEngine.ios.kt | 6 - .../settings/ThemeSettingsStorage.ios.kt | 14 - gradle/libs.versions.toml | 3 - scripts/set-version.sh | 218 -- 177 files changed, 567 insertions(+), 18081 deletions(-) delete mode 100644 composeApp/Configuration/DesktopVersion.properties delete mode 100644 composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppVersionPolicy.android.kt delete mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.android.kt delete mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/SecondaryClick.android.kt delete mode 100644 composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppVersionPolicy.android.kt delete mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppVersionPolicy.kt delete mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.kt delete mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/SecondaryClick.kt delete mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DesktopNavigationLayout.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/Main.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/Platform.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/core/auth/AuthStorage.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppVersionPolicy.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/core/storage/DesktopStorage.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/core/sync/AppForegroundMonitor.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/AppIconPainter.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformBackHandler.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformExitApp.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformImageLoader.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleStorage.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/SecondaryClick.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/collection/CollectionStorage.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsStorage.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/SeasonViewModeStorage.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/components/HeroTrailerPlayerSurface.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsClock.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsLiveStatusPlatform.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsStorage.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsStorage.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/library/LibraryClock.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/library/LibraryStorage.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsClock.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsStorage.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/p2p/P2pSettingsStorage.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngine.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLauncherEffect.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerEngine.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerLanguagePreferences.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopAppFullscreen.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopHostOs.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopPlayerLaunchShield.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopWindowChrome.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/MacosAwtViewResolver.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerBridge.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerController.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerHost.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/skip/DateComponents.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginPlatform.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/AvatarStorage.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCacheStorage.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCrypto.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfileStorage.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/search/SearchHistoryStorage.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/PluginsSettingsPage.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/EpochMs.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheStorage.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trailer/TrailerPlaybackResolver.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktBrandPainter.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsStorage.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktStorage.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watched/WatchedClock.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watched/WatchedStorage.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesStorage.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/ResumePromptStorage.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressClock.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressStorage.desktop.kt delete mode 100644 composeApp/src/desktopMain/native/macos/player_bridge.mm delete mode 100644 composeApp/src/desktopMain/native/windows/player_bridge.cpp delete mode 100644 composeApp/src/desktopMain/resources/icons/nuvio-app-icon.icns delete mode 100644 composeApp/src/desktopMain/resources/icons/nuvio-app-icon.ico delete mode 100644 composeApp/src/desktopMain/resources/icons/nuvio-app-icon.png delete mode 100644 composeApp/src/desktopMain/resources/player-ui/controls.css delete mode 100644 composeApp/src/desktopMain/resources/player-ui/controls.html delete mode 100644 composeApp/src/desktopMain/resources/player-ui/controls.js delete mode 100644 composeApp/src/desktopTest/kotlin/com/nuvio/app/features/plugins/PluginRuntimeDesktopTest.kt delete mode 100644 composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppVersionPolicy.ios.kt delete mode 100644 composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppVersionPolicy.ios.kt delete mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.ios.kt delete mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/SecondaryClick.ios.kt delete mode 100755 scripts/set-version.sh diff --git a/composeApp/Configuration/DesktopVersion.properties b/composeApp/Configuration/DesktopVersion.properties deleted file mode 100644 index 8cf3c539..00000000 --- a/composeApp/Configuration/DesktopVersion.properties +++ /dev/null @@ -1,2 +0,0 @@ -VERSION_NAME=0.1.1 -VERSION_CODE=2 diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index a38669b5..aef2d547 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -5,13 +5,9 @@ import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.Property import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputFile -import org.gradle.api.tasks.InputDirectory import org.gradle.api.tasks.Optional import org.gradle.api.tasks.OutputDirectory -import org.gradle.api.tasks.OutputFile -import org.gradle.api.tasks.Sync import org.gradle.api.tasks.TaskAction -import org.gradle.jvm.tasks.Jar import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask import java.util.Properties @@ -30,12 +26,6 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { @get:Input abstract val appVersionCode: Property - @get:Input - abstract val desktopAppVersionName: Property - - @get:Input - abstract val desktopAppVersionCode: Property - @TaskAction fun generate() { val props = Properties() @@ -122,8 +112,6 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { |object AppVersionConfig { | const val VERSION_NAME = "${appVersionName.get()}" | const val VERSION_CODE = ${appVersionCode.get()} - | const val DESKTOP_VERSION_NAME = "${desktopAppVersionName.get()}" - | const val DESKTOP_VERSION_CODE = ${desktopAppVersionCode.get()} |} """.trimMargin() ) @@ -160,23 +148,6 @@ fun readXcconfigValue(file: File, key: String): String? { ?.second } -fun shellQuote(value: String): String = "'${value.replace("'", "'\"'\"'")}'" - -fun cmdQuote(value: String): String = "\"${value.replace("\"", "\"\"")}\"" - -fun psSingleQuote(value: String): String = "'${value.replace("'", "''")}'" - -fun semanticVersionSortKey(value: String): String = - value.split('.', '-', '_') - .joinToString(".") { part -> - part.toIntOrNull()?.toString()?.padStart(8, '0') ?: part - } - -fun newestDirectory(root: File): File? = - root.takeIf(File::exists) - ?.listFiles(File::isDirectory) - ?.maxByOrNull { semanticVersionSortKey(it.name) } - plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.androidApplication) @@ -200,31 +171,6 @@ val releaseAppVersionName = readXcconfigValue(appVersionConfigFile, "MARKETING_V val releaseAppVersionCode = readXcconfigValue(appVersionConfigFile, "CURRENT_PROJECT_VERSION") ?.toIntOrNull() ?: error("CURRENT_PROJECT_VERSION is missing or invalid in ${appVersionConfigFile.path}") -val desktopVersionConfigFile = rootProject.file("composeApp/Configuration/DesktopVersion.properties") -val desktopVersionProps = Properties().apply { - if (desktopVersionConfigFile.exists()) { - desktopVersionConfigFile.inputStream().use { load(it) } - } -} -val desktopReleaseVersionName = ( - providers.gradleProperty("nuvio.desktop.versionName").orNull - ?: System.getenv("NUVIO_DESKTOP_VERSION_NAME") - ?: supabaseProps.getProperty("NUVIO_DESKTOP_VERSION_NAME") - ?: desktopVersionProps.getProperty("VERSION_NAME") - ?: "0.1.0" - ).trim() -require(desktopReleaseVersionName.isNotBlank()) { - "Desktop version name must not be blank." -} -val desktopReleaseVersionCode = ( - providers.gradleProperty("nuvio.desktop.versionCode").orNull - ?: System.getenv("NUVIO_DESKTOP_VERSION_CODE") - ?: supabaseProps.getProperty("NUVIO_DESKTOP_VERSION_CODE") - ?: desktopVersionProps.getProperty("VERSION_CODE") - )?.trim() - ?.takeIf { it.isNotBlank() } - ?.toIntOrNull() - ?: 1 val iosDistribution = ( providers.gradleProperty("nuvio.ios.distribution").orNull ?: System.getenv("NUVIO_IOS_DISTRIBUTION") @@ -241,7 +187,6 @@ val iosDistributionSourceDir = if (iosDistribution == "full") { } val iosFrameworkBundleId = "com.nuvio.media" val fullCommonSourceDir = project.file("src/fullCommonMain/kotlin") -val fullPluginSourceDir = fullCommonSourceDir.resolve("com/nuvio/app/features/plugins") val generatedRuntimeConfigDir = layout.buildDirectory.dir("generated/runtime-config/kotlin") val requestedGradleTasks = gradle.startParameter.taskNames.map { taskName -> taskName.substringAfterLast(':').lowercase() @@ -260,356 +205,6 @@ val generateRuntimeConfigs = tasks.register("generat localPropertiesFile.set(rootProject.layout.projectDirectory.file("local.properties")) appVersionName.set(releaseAppVersionName) appVersionCode.set(releaseAppVersionCode) - desktopAppVersionName.set(desktopReleaseVersionName) - desktopAppVersionCode.set(desktopReleaseVersionCode) -} - -val isMacHost = System.getProperty("os.name").contains("mac", ignoreCase = true) -val isWindowsHost = System.getProperty("os.name").contains("win", ignoreCase = true) -val mpvKitDir = providers.gradleProperty("nuvio.mpvkit.dir") - .orElse(rootProject.layout.projectDirectory.dir("MPVKit").asFile.absolutePath) -val macosPlayerBridgeSource = layout.projectDirectory.file("src/desktopMain/native/macos/player_bridge.mm") -val macosPlayerBridgeOutput = layout.buildDirectory.file("native/macos/libplayer_bridge.dylib") -val macosPlayerBridgeArch = when (System.getProperty("os.arch").lowercase()) { - "aarch64", "arm64" -> "arm64" - else -> "x86_64" -} -val mpvKitRoot = File(mpvKitDir.get()) -val mpvKitDistRoot = File(mpvKitRoot, "dist") -val mpvKitLibmpvRoot = File(mpvKitDistRoot, "libmpv/macos/thin/$macosPlayerBridgeArch") -val mpvKitLibmpvPkgConfigFile = File(mpvKitLibmpvRoot, "lib/pkgconfig/mpv.pc") -val mpvKitGeneratedPkgConfigDirs = if (mpvKitDistRoot.exists()) { - mpvKitDistRoot.walkTopDown() - .filter { it.isDirectory && it.invariantSeparatorsPath.endsWith("/macos/thin/$macosPlayerBridgeArch/lib/pkgconfig") } - .toList() - .sortedBy { it.absolutePath } -} else { - emptyList() -} -val mpvKitGeneratedLibSearchArgs = mpvKitGeneratedPkgConfigDirs - .mapNotNull { it.parentFile } - .distinctBy { it.absolutePath } - .joinToString(" ") { "-L${shellQuote(it.absolutePath)}" } -val missingMpvKitMacosFrameworks = if (mpvKitLibmpvPkgConfigFile.exists()) emptyList() else listOf("mpv.pc") -val missingMpvKitMacosMessage = """ - MPVKit macOS libmpv artifacts are missing for $macosPlayerBridgeArch: ${missingMpvKitMacosFrameworks.joinToString()}. - Build MPVKit's macOS runtime first: - cd ${mpvKitRoot.absolutePath} - make build platform=macos - Or pass -Pnuvio.mpvkit.dir=/absolute/path/to/MPVKit. -""".trimIndent() -val missingMpvKitMacosShellMessage = missingMpvKitMacosMessage.replace("'", "'\"'\"'") -val macosPlayerBridgeSourceFile = macosPlayerBridgeSource.asFile -val macosPlayerBridgeOutputFile = macosPlayerBridgeOutput.get().asFile -val macosPlayerBridgeJavaHome = providers.systemProperty("java.home").get() -val mpvKitLibmpvStaticLib = File(mpvKitLibmpvRoot, "lib/libmpv.a") -if (isMacHost) { - macosPlayerBridgeOutputFile.parentFile.mkdirs() -} -val macosPlayerBridgeCommand = if (missingMpvKitMacosFrameworks.isNotEmpty()) { - listOf( - "/bin/sh", - "-c", - "printf '%s\\n' '$missingMpvKitMacosShellMessage' >&2; exit 1", - ) -} else { - mutableListOf( - "/bin/sh", - "-c", - """ - set -eu - SDKROOT="${'$'}(xcrun --sdk macosx --show-sdk-path)" - SWIFTC="${'$'}(xcrun --toolchain XcodeDefault --find swiftc)" - SWIFT_TOOLCHAIN="${'$'}{SWIFTC%/usr/bin/swiftc}" - SWIFT_LIB="${'$'}{SWIFT_TOOLCHAIN}/usr/lib/swift/macosx" - DEFAULT_PC="${'$'}(pkg-config --variable pc_path pkg-config)" - export PKG_CONFIG_LIBDIR=${shellQuote(mpvKitGeneratedPkgConfigDirs.joinToString(":"))}:"${'$'}{DEFAULT_PC}" - exec xcrun clang++ \ - -std=c++17 \ - -dynamiclib \ - -fobjc-arc \ - -ObjC++ \ - -arch ${shellQuote(macosPlayerBridgeArch)} \ - -isysroot "${'$'}{SDKROOT}" \ - -mmacosx-version-min=11.0 \ - ${shellQuote(macosPlayerBridgeSourceFile.absolutePath)} \ - -o ${shellQuote(macosPlayerBridgeOutputFile.absolutePath)} \ - -I${shellQuote("$macosPlayerBridgeJavaHome/include")} \ - -I${shellQuote("$macosPlayerBridgeJavaHome/include/darwin")} \ - -I${shellQuote(File(mpvKitLibmpvRoot, "include").absolutePath)} \ - $mpvKitGeneratedLibSearchArgs \ - -L"${'$'}{SWIFT_LIB}" \ - -L/usr/lib/swift \ - -framework AppKit \ - -framework WebKit \ - -framework Metal \ - -framework Security \ - -lswiftCompatibility56 \ - -lswiftCompatibilityConcurrency \ - -lswiftCompatibilityPacks \ - -lc++ \ - ${'$'}(pkg-config --libs --static mpv) - """.trimIndent(), - ) -} -val buildMacosPlayerBridge = tasks.register("buildMacosPlayerBridge") { - notCompatibleWithConfigurationCache("Builds a host-local player bridge against MPVKit's macOS libmpv artifacts.") - enabled = isMacHost - inputs.file(macosPlayerBridgeSource) - if (mpvKitLibmpvStaticLib.exists()) { - inputs.file(mpvKitLibmpvStaticLib) - } - if (mpvKitLibmpvPkgConfigFile.exists()) { - inputs.file(mpvKitLibmpvPkgConfigFile) - } - inputs.files(mpvKitGeneratedPkgConfigDirs.mapNotNull { it.parentFile?.resolve("lib")?.takeIf(File::exists) }) - outputs.file(macosPlayerBridgeOutput) - commandLine(macosPlayerBridgeCommand) -} - -val windowsPlayerBridgeArch = when (System.getProperty("os.arch").lowercase()) { - "aarch64", "arm64" -> "arm64" - "x86" -> "x86" - else -> "x64" -} -val windowsPlayerBridgeSource = layout.projectDirectory.file("src/desktopMain/native/windows/player_bridge.cpp") -val windowsPlayerBridgeOutput = layout.buildDirectory.file("native/windows/player_bridge.dll") -val windowsPlayerBridgeImportLib = layout.buildDirectory.file("native/windows/player_bridge.lib") -val windowsPlayerBridgePdb = layout.buildDirectory.file("native/windows/player_bridge.pdb") -val windowsPlayerBridgeObj = layout.buildDirectory.file("native/windows/player_bridge.obj") -val windowsPlayerBridgeScript = layout.buildDirectory.file("native/windows/build-player-bridge.bat") -val windowsPlayerRuntimeOutput = layout.buildDirectory.dir("native/windows-runtime") -if (isWindowsHost) { - windowsPlayerBridgeOutput.get().asFile.parentFile.mkdirs() -} -val windowsWebView2Root = providers.gradleProperty("nuvio.webview2.dir").orNull - ?.takeIf { it.isNotBlank() } - ?.let(::File) - ?: newestDirectory(File(System.getProperty("user.home"), ".nuget/packages/microsoft.web.webview2")) - ?: File("__missing_webview2__") -val windowsWebView2IncludeDir = File(windowsWebView2Root, "build/native/include") -val windowsWebView2NativeDir = File(windowsWebView2Root, "build/native/$windowsPlayerBridgeArch") -val windowsWebView2LoaderLib = File(windowsWebView2NativeDir, "WebView2Loader.dll.lib") -val windowsWebView2LoaderDll = File(windowsWebView2NativeDir, "WebView2Loader.dll") -val windowsLibmpvRuntimeDir = providers.gradleProperty("nuvio.windows.libmpv.runtimeDir").orNull - ?.takeIf { it.isNotBlank() } - ?.let(::File) - ?: listOf( - File("C:/Program Files (x86)/Nuvio/app/native"), - File("C:/Program Files/Nuvio/app/native"), - ).firstOrNull { File(it, "libmpv-2.dll").exists() } -val windowsLibmpvDll = providers.gradleProperty("nuvio.windows.libmpv.dll").orNull - ?.takeIf { it.isNotBlank() } - ?.let(::File) - ?: windowsLibmpvRuntimeDir?.resolve("libmpv-2.dll") - ?: listOf( - File("C:/msys64/ucrt64/bin/libmpv-2.dll"), - File("C:/msys64/mingw64/bin/libmpv-2.dll"), - ).firstOrNull(File::exists) -val windowsVsWhere = File("C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe") -val windowsVcvarsRelativePath = when (windowsPlayerBridgeArch) { - "x86" -> "VC\\Auxiliary\\Build\\vcvars32.bat" - "arm64" -> "VC\\Auxiliary\\Build\\vcvarsarm64.bat" - else -> "VC\\Auxiliary\\Build\\vcvars64.bat" -} -val windowsVcvarsPath = providers.gradleProperty("nuvio.windows.vcvars.path").orNull - ?.takeIf { it.isNotBlank() } -val windowsPlayerBridgeJavaHome = providers.systemProperty("java.home").get() -val missingWindowsPlayerBridgeInputs = listOfNotNull( - "WebView2.h".takeUnless { windowsWebView2IncludeDir.resolve("WebView2.h").exists() }, - "WebView2Loader.dll.lib".takeUnless { windowsWebView2LoaderLib.exists() }, -) -val missingWindowsPlayerBridgeMessage = """ - Windows desktop player bridge inputs are missing: ${missingWindowsPlayerBridgeInputs.joinToString()}. - Install the Microsoft.Web.WebView2 NuGet package or pass -Pnuvio.webview2.dir=C:/path/to/microsoft.web.webview2/version. - libmpv is loaded at runtime; pass -Pnuvio.windows.libmpv.runtimeDir=C:/path/to/mpv-dlls to bundle it. -""".trimIndent() -val windowsPlayerBridgeCommand = if (missingWindowsPlayerBridgeInputs.isNotEmpty()) { - listOf( - "cmd", - "/c", - "echo ${missingWindowsPlayerBridgeMessage.replace("\n", " ")} 1>&2 && exit /b 1", - ) -} else { - val sourceFile = windowsPlayerBridgeSource.asFile - val outputFile = windowsPlayerBridgeOutput.get().asFile - val importLibFile = windowsPlayerBridgeImportLib.get().asFile - val pdbFile = windowsPlayerBridgePdb.get().asFile - val objFile = windowsPlayerBridgeObj.get().asFile - val javaIncludeDir = File(windowsPlayerBridgeJavaHome, "include") - val javaWin32IncludeDir = File(javaIncludeDir, "win32") - val compileCommand = listOf( - "cl", - "/nologo", - "/EHsc", - "/std:c++17", - "/LD", - "/DUNICODE", - "/D_UNICODE", - "/DNOMINMAX", - "/DWIN32_LEAN_AND_MEAN", - "/permissive-", - cmdQuote(sourceFile.absolutePath), - "/I${cmdQuote(javaIncludeDir.absolutePath)}", - "/I${cmdQuote(javaWin32IncludeDir.absolutePath)}", - "/I${cmdQuote(windowsWebView2IncludeDir.absolutePath)}", - "/Fo${cmdQuote(objFile.absolutePath)}", - "/Fd${cmdQuote(pdbFile.absolutePath)}", - "/Fe${cmdQuote(outputFile.absolutePath)}", - "/link", - "/NOLOGO", - "/INCREMENTAL:NO", - "/IMPLIB:${cmdQuote(importLibFile.absolutePath)}", - cmdQuote(windowsWebView2LoaderLib.absolutePath), - "Ole32.lib", - "User32.lib", - "Gdi32.lib", - "Dwmapi.lib", - ).joinToString(" ") - val powershellCompileCommand = compileCommand.replace("\"", "__DQ__") - val powershellCommand = """ - ${'$'}ErrorActionPreference = 'Stop' - ${'$'}dq = [char]34 - ${'$'}vcvars = ${psSingleQuote(windowsVcvarsPath.orEmpty())} - if ([string]::IsNullOrWhiteSpace(${'$'}vcvars)) { - ${'$'}vswhere = ${psSingleQuote(windowsVsWhere.absolutePath)} - if (Test-Path -LiteralPath ${'$'}vswhere) { - ${'$'}vcvars = & ${'$'}vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -find ${psSingleQuote(windowsVcvarsRelativePath)} | Select-Object -First 1 - } - } - if ([string]::IsNullOrWhiteSpace(${'$'}vcvars) -or -not (Test-Path -LiteralPath ${'$'}vcvars)) { - Write-Error 'Visual Studio C++ toolchain was not found. Install MSVC or pass -Pnuvio.windows.vcvars.path=C:\path\to\vcvars64.bat.' - exit 1 - } - ${'$'}vcvars = ([string]${'$'}vcvars).Trim() - ${'$'}bat = ${psSingleQuote(windowsPlayerBridgeScript.get().asFile.absolutePath)} - ${'$'}compile = ${psSingleQuote(powershellCompileCommand)}.Replace('__DQ__', ${'$'}dq) - ${'$'}lines = @( - '@echo off', - ('set {0}VCVARS={1}{0}' -f ${'$'}dq, ${'$'}vcvars), - ('call {0}%VCVARS%{0} >nul' -f ${'$'}dq), - 'if errorlevel 1 exit /b %errorlevel%', - ${'$'}compile, - 'exit /b %ERRORLEVEL%' - ) - Set-Content -LiteralPath ${'$'}bat -Value ${'$'}lines -Encoding ASCII - & cmd.exe /d /c ${'$'}bat - ${'$'}code = ${'$'}LASTEXITCODE - if (${'$'}code -ne 0) { exit ${'$'}code } - """.trimIndent() - listOf( - "powershell", - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-Command", - powershellCommand, - ) -} -val buildWindowsPlayerBridge = tasks.register("buildWindowsPlayerBridge") { - notCompatibleWithConfigurationCache("Builds a host-local player bridge against WebView2 and libmpv for Windows.") - enabled = isWindowsHost - inputs.file(windowsPlayerBridgeSource) - if (windowsWebView2IncludeDir.exists()) { - inputs.dir(windowsWebView2IncludeDir) - } - if (windowsWebView2LoaderLib.exists()) { - inputs.file(windowsWebView2LoaderLib) - } - outputs.file(windowsPlayerBridgeOutput) - outputs.file(windowsPlayerBridgeImportLib) - outputs.file(windowsPlayerBridgePdb) - commandLine(windowsPlayerBridgeCommand) -} - -val prepareWindowsPlayerRuntime = tasks.register("prepareWindowsPlayerRuntime") { - enabled = isWindowsHost - into(windowsPlayerRuntimeOutput) - if (windowsWebView2LoaderDll.exists()) { - from(windowsWebView2LoaderDll) - } - when { - windowsLibmpvRuntimeDir?.exists() == true -> { - from(windowsLibmpvRuntimeDir) { - include("*.dll") - } - } - windowsLibmpvDll?.exists() == true -> { - from(windowsLibmpvDll) - } - } -} - -val generateWindowsPlayerRuntimeIndex = tasks.register("generateWindowsPlayerRuntimeIndex") { - enabled = isWindowsHost - dependsOn(prepareWindowsPlayerRuntime) - runtimeDir.set(windowsPlayerRuntimeOutput) - indexFile.set(windowsPlayerRuntimeOutput.map { it.file("runtime-files.txt") }) -} - -abstract class GenerateNativeRuntimeIndexTask : DefaultTask() { - @get:InputDirectory - abstract val runtimeDir: DirectoryProperty - - @get:OutputFile - abstract val indexFile: RegularFileProperty - - @TaskAction - fun generate() { - val dir = runtimeDir.get().asFile - val files = dir - .listFiles { file -> file.isFile && file.name != indexFile.get().asFile.name } - .orEmpty() - .map { it.name } - .sorted() - indexFile.get().asFile.writeText(files.joinToString(separator = "\n", postfix = "\n")) - } -} - -tasks.withType().configureEach { - if (isMacHost && name == "desktopJar") { - dependsOn(buildMacosPlayerBridge) - from(macosPlayerBridgeOutput) { - into("native/macos") - } - } - if (isWindowsHost && name == "desktopJar") { - dependsOn(buildWindowsPlayerBridge, prepareWindowsPlayerRuntime, generateWindowsPlayerRuntimeIndex) - from(windowsPlayerBridgeOutput) { - into("native/windows") - } - from(windowsPlayerRuntimeOutput) { - into("native/windows") - } - } -} - -if (isWindowsHost) { - val desktopNativePlayerTasks = setOf( - "run", - "runRelease", - "desktopRun", - "runDistributable", - "runReleaseDistributable", - "desktopRunHot", - "hotRunDesktop", - "hotRunDesktopAsync", - "hotDevDesktop", - "hotDevDesktopAsync", - "createDistributable", - "createReleaseDistributable", - "createRuntimeImage", - "package", - "packageDistributionForCurrentOS", - "packageMsi", - "packageUberJarForCurrentOS", - "packageReleaseDistributionForCurrentOS", - "packageReleaseMsi", - "packageReleaseUberJarForCurrentOS", - ) - tasks.matching { it.name in desktopNativePlayerTasks }.configureEach { - dependsOn(buildWindowsPlayerBridge, prepareWindowsPlayerRuntime, generateWindowsPlayerRuntimeIndex) - } } tasks.withType>().configureEach { @@ -622,12 +217,6 @@ kotlin { jvmTarget.set(JvmTarget.JVM_11) } } - - jvm("desktop") { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_11) - } - } val iosTargets = listOf( iosArm64(), @@ -692,16 +281,6 @@ kotlin { implementation(libs.androidx.media3.extractor) implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("lib-*.aar")))) } - val desktopMain by getting { - kotlin.srcDir(fullPluginSourceDir) - dependencies { - implementation(compose.desktop.currentOs) - implementation(libs.kotlinx.coroutines.swing) - implementation(libs.ktor.client.cio) - implementation(libs.quickjs.kt) - implementation(libs.ksoup) - } - } commonMain.dependencies { implementation(libs.coil.compose) implementation(libs.coil.network.ktor3) @@ -730,46 +309,6 @@ kotlin { } } -compose.desktop { - application { - mainClass = "com.nuvio.app.MainKt" - val smokePlayerUrl = providers.gradleProperty("nuvio.desktop.smokePlayerUrl").orNull - ?: System.getenv("NUVIO_DESKTOP_SMOKE_PLAYER_URL") - jvmArgs += listOfNotNull( - "-Dapple.awt.application.appearance=NSAppearanceNameDarkAqua", - "--add-opens=java.desktop/java.awt=ALL-UNNAMED", - "--add-opens=java.desktop/sun.lwawt=ALL-UNNAMED", - "--add-opens=java.desktop/sun.lwawt.macosx=ALL-UNNAMED", - "--add-opens=java.desktop/sun.awt.windows=ALL-UNNAMED", - smokePlayerUrl?.takeIf { it.isNotBlank() }?.let { "-Dnuvio.desktop.smokePlayerUrl=$it" }, - ) - - nativeDistributions { - targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) - packageName = "Nuvio" - packageVersion = desktopReleaseVersionName - vendor = "Nuvio Media" - modules("java.net.http") - macOS { - iconFile.set(project.file("src/desktopMain/resources/icons/nuvio-app-icon.icns")) - } - windows { - iconFile.set(project.file("src/desktopMain/resources/icons/nuvio-app-icon.ico")) - shortcut = true - menu = true - menuGroup = "Nuvio" - } - linux { - iconFile.set(project.file("src/desktopMain/resources/icons/nuvio-app-icon.png")) - } - } - - buildTypes.release.proguard { - isEnabled.set(false) - } - } -} - afterEvaluate { dependencies { add("fullImplementation", files("libs/quickjs-kt-android-1.0.5-nuvio.aar")) diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt index 9d407edf..996401f6 100644 --- a/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt @@ -2,8 +2,6 @@ package com.nuvio.app.core.build actual object AppFeaturePolicy { actual val pluginsEnabled: Boolean = true - actual val downloadsEnabled: Boolean = true - actual val notificationsEnabled: Boolean = true actual val p2pEnabled: Boolean = true actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.IN_APP actual val heroTrailerPlaybackSupported: Boolean = true diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppVersionPolicy.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppVersionPolicy.android.kt deleted file mode 100644 index 1d181ca6..00000000 --- a/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppVersionPolicy.android.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.nuvio.app.core.build - -actual object AppVersionPolicy { - actual val displayVersionName: String = AppVersionConfig.VERSION_NAME - actual val displayVersionCode: Int = AppVersionConfig.VERSION_CODE - actual val basedOnVersionName: String? = null -} diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt index f44e40cd..6e77db32 100644 --- a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt @@ -26,6 +26,4 @@ internal object PluginStorage { internal fun currentPluginPlatform(): String = "android" -internal fun currentPluginPlatformTags(): Set = setOf(currentPluginPlatform()) - internal fun currentEpochMillis(): Long = System.currentTimeMillis() diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/Platform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/Platform.android.kt index 2c5d6eca..5ba9da3c 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/Platform.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/Platform.android.kt @@ -8,5 +8,4 @@ class AndroidPlatform : Platform { actual fun getPlatform(): Platform = AndroidPlatform() -internal actual val isIos: Boolean = false -internal actual val isDesktop: Boolean = false +internal actual val isIos: Boolean = false \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.android.kt deleted file mode 100644 index fa48a20c..00000000 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.android.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.nuvio.app.core.ui - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.FilterQuality -import androidx.compose.ui.graphics.drawscope.DrawScope.Companion.DefaultFilterQuality -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.layout.ContentScale -import coil3.compose.AsyncImage -import coil3.compose.AsyncImagePainter - -@Composable -internal actual fun NuvioAsyncImage( - model: Any?, - contentDescription: String?, - modifier: Modifier, - placeholder: Painter?, - error: Painter?, - fallback: Painter?, - onLoading: ((AsyncImagePainter.State.Loading) -> Unit)?, - onSuccess: ((AsyncImagePainter.State.Success) -> Unit)?, - onError: ((AsyncImagePainter.State.Error) -> Unit)?, - alignment: Alignment, - contentScale: ContentScale, - alpha: Float, - colorFilter: ColorFilter?, - filterQuality: FilterQuality?, - clipToBounds: Boolean, - desktopImageScaling: NuvioDesktopImageScaling, -) { - AsyncImage( - model = model, - contentDescription = contentDescription, - modifier = modifier, - placeholder = placeholder, - error = error, - fallback = fallback, - onLoading = onLoading, - onSuccess = onSuccess, - onError = onError, - alignment = alignment, - contentScale = contentScale, - alpha = alpha, - colorFilter = colorFilter, - filterQuality = filterQuality ?: DefaultFilterQuality, - clipToBounds = clipToBounds, - ) -} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/SecondaryClick.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/SecondaryClick.android.kt deleted file mode 100644 index a01bf27a..00000000 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/SecondaryClick.android.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.nuvio.app.core.ui - -import androidx.compose.ui.Modifier - -internal actual fun Modifier.secondaryClick(onClick: (() -> Unit)?): Modifier = this diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index e4e83323..c938387f 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -79,13 +79,7 @@ actual fun PlatformPlayerSurface( modifier: Modifier, playWhenReady: Boolean, resizeMode: PlayerResizeMode, - initialPositionMs: Long, useNativeController: Boolean, - playerControlsState: PlayerControlsState, - onPlayerControlsAction: (PlayerControlsAction) -> Boolean, - onPlayerControlsEvent: (String, Double) -> Boolean, - onPlayerControlsScrubChange: (Long) -> Boolean, - onPlayerControlsScrubFinished: (Long) -> Boolean, onControllerReady: (PlayerEngineController) -> Unit, onSnapshot: (PlayerPlaybackSnapshot) -> Unit, onError: (String?) -> Unit, diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt index 0d8e5b9b..6a2e1584 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt @@ -18,13 +18,11 @@ actual object ThemeSettingsStorage { private const val selectedThemeKey = "selected_theme" private const val amoledEnabledKey = "amoled_enabled" private const val liquidGlassNativeTabBarEnabledKey = "liquid_glass_native_tab_bar_enabled" - private const val desktopNavigationLayoutKey = "desktop_navigation_layout" private const val selectedAppLanguageKey = "selected_app_language" private val profileScopedSyncKeys = listOf( selectedThemeKey, amoledEnabledKey, liquidGlassNativeTabBarEnabledKey, - desktopNavigationLayoutKey, ) private var preferences: SharedPreferences? = null @@ -70,16 +68,6 @@ actual object ThemeSettingsStorage { ?.apply() } - actual fun loadDesktopNavigationLayout(): String? = - preferences?.getString(ProfileScopedKey.of(desktopNavigationLayoutKey), null) - - actual fun saveDesktopNavigationLayout(layoutName: String) { - preferences - ?.edit() - ?.putString(ProfileScopedKey.of(desktopNavigationLayoutKey), layoutName) - ?.apply() - } - actual fun loadSelectedAppLanguage(): String? { val value = preferences?.getString(selectedAppLanguageKey, null) if (value != null) return value @@ -105,7 +93,6 @@ actual object ThemeSettingsStorage { loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) } loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) } loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) } - loadDesktopNavigationLayout()?.let { put(desktopNavigationLayoutKey, encodeSyncString(it)) } } actual fun replaceFromSyncPayload(payload: JsonObject) { @@ -116,7 +103,6 @@ actual object ThemeSettingsStorage { payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme) payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled) payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled) - payload.decodeSyncString(desktopNavigationLayoutKey)?.let(::saveDesktopNavigationLayout) applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code) } } diff --git a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt index 4ceee466..2b308a23 100644 --- a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt +++ b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt @@ -2,8 +2,6 @@ package com.nuvio.app.core.build actual object AppFeaturePolicy { actual val pluginsEnabled: Boolean = false - actual val downloadsEnabled: Boolean = true - actual val notificationsEnabled: Boolean = true actual val p2pEnabled: Boolean = true actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.EXTERNAL actual val heroTrailerPlaybackSupported: Boolean = false diff --git a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppVersionPolicy.android.kt b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppVersionPolicy.android.kt deleted file mode 100644 index 1d181ca6..00000000 --- a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppVersionPolicy.android.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.nuvio.app.core.build - -actual object AppVersionPolicy { - actual val displayVersionName: String = AppVersionConfig.VERSION_NAME - actual val displayVersionCode: Int = AppVersionConfig.VERSION_CODE - actual val basedOnVersionName: String? = null -} diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index f849d933..be681d8d 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -300,7 +300,6 @@ Pinned All Your Collections - Based on Nuvio %1$s Made with ❤️ by Tapframe and friends Version %1$s (%2$s) Off @@ -536,10 +535,6 @@ App Language Choose Language Settings for the Continue Watching section. - Desktop Navigation - Choose Desktop Navigation - Sidebar - Top Bar Liquid Glass Use the native iPhone tab bar on iOS 26 and later. Instant profile switching from the tab bar is unavailable while this is on. Tune card width and corner radius. diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 458c752c..a10af383 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -3,7 +3,6 @@ package com.nuvio.app import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout -import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -12,9 +11,6 @@ import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.Image -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -23,18 +19,15 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Home -import androidx.compose.material.icons.rounded.Settings import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember @@ -62,10 +55,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.max import androidx.compose.ui.zIndex import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver @@ -163,15 +153,12 @@ import com.nuvio.app.features.player.prepareExternalPlayerLaunch import com.nuvio.app.features.player.SubtitleLanguageOption import com.nuvio.app.features.player.sanitizePlaybackHeaders import com.nuvio.app.features.player.sanitizePlaybackResponseHeaders -import com.nuvio.app.features.profiles.ActiveProfileMiniAvatar -import com.nuvio.app.features.profiles.AvatarCatalogItem import com.nuvio.app.features.profiles.AvatarRepository import com.nuvio.app.features.profiles.NuvioProfile import com.nuvio.app.features.profiles.ProfileEditScreen import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.profiles.ProfileSelectionScreen import com.nuvio.app.features.profiles.ProfileSwitcherTab -import com.nuvio.app.features.profiles.SidebarProfileSwitcherStack import com.nuvio.app.features.profiles.profileAvatarImageUrl import com.nuvio.app.features.search.SearchScreen import com.nuvio.app.features.settings.SettingsScreen @@ -181,16 +168,13 @@ import com.nuvio.app.features.settings.ContinueWatchingSettingsScreen import com.nuvio.app.features.settings.AddonsSettingsScreen import com.nuvio.app.features.settings.PluginsSettingsScreen import com.nuvio.app.features.settings.AccountSettingsScreen -import com.nuvio.app.features.settings.DesktopNavigationLayout import com.nuvio.app.features.settings.SupportersContributorsSettingsScreen import com.nuvio.app.features.settings.LicensesAttributionsSettingsScreen import com.nuvio.app.features.settings.ThemeSettingsRepository import com.nuvio.app.features.collection.CollectionManagementScreen import com.nuvio.app.features.collection.CollectionEditorScreen import com.nuvio.app.features.collection.CollectionEditorRepository -import com.nuvio.app.features.collection.CollectionRepository import com.nuvio.app.features.collection.CollectionSyncService -import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.HomeCatalogSettingsSyncService import com.nuvio.app.features.collection.FolderDetailScreen import com.nuvio.app.features.collection.FolderDetailRepository @@ -208,7 +192,6 @@ import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktListTab import com.nuvio.app.features.trakt.TraktScrobbleRepository -import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.features.updater.AppUpdaterHost import com.nuvio.app.features.updater.rememberAppUpdaterController import com.nuvio.app.features.watched.WatchedRepository @@ -221,13 +204,10 @@ import com.nuvio.app.features.watchprogress.nextUpDismissKey import com.nuvio.app.features.watchprogress.toContinueWatchingItem import com.nuvio.app.features.watching.application.WatchingActions import com.nuvio.app.features.watching.application.WatchingState -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import nuvio.composeapp.generated.resources.* import nuvio.composeapp.generated.resources.app_logo_wordmark @@ -341,11 +321,6 @@ enum class AppScreenTab { Settings, } -private val DesktopSidebarCollapsedWidth = 76.dp -private val DesktopSidebarExpandedWidth = 184.dp -private val DesktopSidebarExpandedContentWidth = 144.dp -private val DesktopSidebarIconSlotSize = 36.dp - private fun AppScreenTab.toNativeNavigationTab(): NativeNavigationTab = when (this) { AppScreenTab.Home -> NativeNavigationTab.Home AppScreenTab.Search -> NativeNavigationTab.Search @@ -376,37 +351,10 @@ private enum class AppGateScreen { Loading, Auth, ProfileSelection, - ProfileSwitching, ProfileEdit, Main, } -private data class PendingProfileSwitch( - val profile: NuvioProfile, - val syncOnEnter: Boolean, -) - -private suspend fun warmProfileBoundRepositories() { - withContext(Dispatchers.Default) { - AddonRepository.initialize() - CollectionRepository.initialize() - ContinueWatchingPreferencesRepository.ensureLoaded() - DownloadsRepository.ensureLoaded() - EpisodeReleaseNotificationsRepository.ensureLoaded() - HomeCatalogSettingsRepository.snapshot() - LibraryRepository.ensureLoaded() - P2pSettingsRepository.ensureLoaded() - PlayerSettingsRepository.ensureLoaded() - TraktAuthRepository.ensureLoaded() - TraktSettingsRepository.ensureLoaded() - WatchedRepository.ensureLoaded() - WatchProgressRepository.ensureLoaded() - CollectionSyncService.startObserving() - HomeCatalogSettingsSyncService.startObserving() - ProfileSettingsSync.startObserving() - } -} - @OptIn(ExperimentalMaterial3Api::class) @Composable @Preview @@ -469,7 +417,6 @@ fun App() { var editingProfile by remember { mutableStateOf(null) } var isNewProfile by remember { mutableStateOf(false) } var autoSkipProfileSelection by rememberSaveable { mutableStateOf(false) } - var pendingProfileSwitch by remember { mutableStateOf(null) } fun rememberedStartupProfile(profiles: List): NuvioProfile? { val currentProfileState = ProfileRepository.state.value @@ -485,12 +432,6 @@ fun App() { ?.takeUnless { it.pinEnabled } } - fun requestProfileSwitch(profile: NuvioProfile, syncOnEnter: Boolean) { - autoSkipProfileSelection = false - pendingProfileSwitch = PendingProfileSwitch(profile, syncOnEnter) - gateScreen = AppGateScreen.ProfileSwitching.name - } - fun enterProfileGate(profiles: List, syncOnEnter: Boolean) { if (profiles.isEmpty()) { autoSkipProfileSelection = true @@ -499,7 +440,12 @@ fun App() { } rememberedStartupProfile(profiles)?.let { profile -> - requestProfileSwitch(profile, syncOnEnter) + ProfileRepository.selectProfile(profile.profileIndex) + if (syncOnEnter) { + SyncManager.pullAllForProfile(profile.profileIndex) + } + gateScreen = AppGateScreen.Main.name + autoSkipProfileSelection = false return } @@ -510,40 +456,18 @@ fun App() { gateScreen = AppGateScreen.ProfileSelection.name return } - requestProfileSwitch(onlyProfile, syncOnEnter) + ProfileRepository.selectProfile(onlyProfile.profileIndex) + if (syncOnEnter) { + SyncManager.pullAllForProfile(onlyProfile.profileIndex) + } + gateScreen = AppGateScreen.Main.name + autoSkipProfileSelection = false } else { gateScreen = AppGateScreen.ProfileSelection.name } } - LaunchedEffect(gateScreen, pendingProfileSwitch) { - if (gateScreen == AppGateScreen.ProfileSwitching.name && pendingProfileSwitch == null) { - gateScreen = AppGateScreen.Loading.name - } - } - - LaunchedEffect(pendingProfileSwitch) { - val request = pendingProfileSwitch ?: return@LaunchedEffect - runCatching { - ProfileRepository.switchToProfile(request.profile.profileIndex) - warmProfileBoundRepositories() - if (request.syncOnEnter) { - SyncManager.pullAllForProfile(request.profile.profileIndex) - } - }.onSuccess { - pendingProfileSwitch = null - autoSkipProfileSelection = false - gateScreen = AppGateScreen.Main.name - }.onFailure { - pendingProfileSwitch = null - autoSkipProfileSelection = false - gateScreen = AppGateScreen.ProfileSelection.name - } - } - LaunchedEffect(authState, networkStatusUiState.condition, profileState.profiles) { - if (gateScreen == AppGateScreen.ProfileSwitching.name) return@LaunchedEffect - val cachedProfiles = profileState.profiles val hasCachedProfileAccess = cachedProfiles.isNotEmpty() && @@ -601,10 +525,10 @@ fun App() { gateScreen == AppGateScreen.ProfileSelection.name ) { rememberedStartupProfile(profileState.profiles)?.let { profile -> - requestProfileSwitch( - profile = profile, - syncOnEnter = authState is AuthState.Authenticated, - ) + ProfileRepository.selectProfile(profile.profileIndex) + SyncManager.pullAllForProfile(profile.profileIndex) + gateScreen = AppGateScreen.Main.name + autoSkipProfileSelection = false return@LaunchedEffect } @@ -613,10 +537,10 @@ fun App() { val onlyProfile = profileState.profiles.first() if (onlyProfile.pinEnabled) return@LaunchedEffect - requestProfileSwitch( - profile = onlyProfile, - syncOnEnter = authState is AuthState.Authenticated, - ) + ProfileRepository.selectProfile(onlyProfile.profileIndex) + SyncManager.pullAllForProfile(onlyProfile.profileIndex) + gateScreen = AppGateScreen.Main.name + autoSkipProfileSelection = false } } @@ -629,9 +553,15 @@ fun App() { }, ) { currentGate -> when (currentGate) { - AppGateScreen.Loading.name, - AppGateScreen.ProfileSwitching.name -> { - AppLaunchOverlay(modifier = Modifier.fillMaxSize()) + AppGateScreen.Loading.name -> { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.nuvio.colors.background), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(color = MaterialTheme.nuvio.colors.accent) + } } AppGateScreen.Auth.name -> { AuthScreen(modifier = Modifier.fillMaxSize()) @@ -644,10 +574,11 @@ fun App() { } ProfileSelectionScreen( onProfileSelected = { profile -> - requestProfileSwitch( - profile = profile, - syncOnEnter = authState is AuthState.Authenticated, - ) + ProfileRepository.selectProfile(profile.profileIndex) + if (authState is AuthState.Authenticated) { + SyncManager.pullAllForProfile(profile.profileIndex) + } + gateScreen = AppGateScreen.Main.name }, onEditProfile = { profile -> editingProfile = profile @@ -693,6 +624,18 @@ private fun MainAppContent( ) { val navController = rememberNavController() val appUpdaterController = rememberAppUpdaterController() + remember { + EpisodeReleaseNotificationsRepository.ensureLoaded() + } + remember { + CollectionSyncService.startObserving() + } + remember { + HomeCatalogSettingsSyncService.startObserving() + } + remember { + ProfileSettingsSync.startObserving() + } val hapticFeedback = LocalHapticFeedback.current val coroutineScope = rememberCoroutineScope() var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) } @@ -701,17 +644,10 @@ private fun MainAppContent( val searchScrollToTopRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } val libraryScrollToTopRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } val settingsRootActionRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } - - LaunchedEffect(Unit) { - warmProfileBoundRepositories() - } val currentBackStackEntry by navController.currentBackStackEntryAsState() val liquidGlassNativeTabBarEnabled by remember { ThemeSettingsRepository.liquidGlassNativeTabBarEnabled }.collectAsStateWithLifecycle() - val desktopNavigationLayout by remember { - ThemeSettingsRepository.desktopNavigationLayout - }.collectAsStateWithLifecycle() val liquidGlassNativeTabBarSupported = remember { isLiquidGlassNativeTabBarSupported() } var showExitConfirmation by rememberSaveable { mutableStateOf(false) } var selectedPosterActionTarget by remember { mutableStateOf(null) } @@ -724,14 +660,32 @@ private fun MainAppContent( var pickerMembership by remember { mutableStateOf>(emptyMap()) } var pickerPending by remember { mutableStateOf(false) } var pickerError by remember { mutableStateOf(null) } - val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle() - val libraryUiState by LibraryRepository.uiState.collectAsStateWithLifecycle() + val addonsUiState by remember { + AddonRepository.initialize() + AddonRepository.uiState + }.collectAsStateWithLifecycle() + val libraryUiState by remember { + LibraryRepository.ensureLoaded() + LibraryRepository.uiState + }.collectAsStateWithLifecycle() val authState by AuthRepository.state.collectAsStateWithLifecycle() val profileState by ProfileRepository.state.collectAsStateWithLifecycle() - val playerSettingsUiState by PlayerSettingsRepository.uiState.collectAsStateWithLifecycle() - val p2pSettingsUiState by P2pSettingsRepository.uiState.collectAsStateWithLifecycle() - val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle() - val downloadsUiState by DownloadsRepository.uiState.collectAsStateWithLifecycle() + val playerSettingsUiState by remember { + PlayerSettingsRepository.ensureLoaded() + PlayerSettingsRepository.uiState + }.collectAsStateWithLifecycle() + val p2pSettingsUiState by remember { + P2pSettingsRepository.ensureLoaded() + P2pSettingsRepository.uiState + }.collectAsStateWithLifecycle() + val watchedUiState by remember { + WatchedRepository.ensureLoaded() + WatchedRepository.uiState + }.collectAsStateWithLifecycle() + val downloadsUiState by remember { + DownloadsRepository.ensureLoaded() + DownloadsRepository.uiState + }.collectAsStateWithLifecycle() val networkStatusUiState by remember { NetworkStatusRepository.uiState }.collectAsStateWithLifecycle() @@ -877,7 +831,6 @@ private fun MainAppContent( NetworkCondition.ServersUnreachable, -> { offlineLaunchRouteHandled = true - if (!AppFeaturePolicy.downloadsEnabled) return@LaunchedEffect val hasPlayableDownload = downloadsUiState.completedItems.any { DownloadsRepository.playableLocalFileUri(it) != null } @@ -965,7 +918,10 @@ private fun MainAppContent( } } } - val continueWatchingPreferencesUiState by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle() + val continueWatchingPreferencesUiState by remember { + ContinueWatchingPreferencesRepository.ensureLoaded() + ContinueWatchingPreferencesRepository.uiState + }.collectAsStateWithLifecycle() LaunchedEffect( initialHomeReady, @@ -1000,11 +956,9 @@ private fun MainAppContent( } AppDeepLink.Downloads -> { - if (AppFeaturePolicy.downloadsEnabled) { - selectedTab = AppScreenTab.Settings - navController.navigate(DownloadsSettingsRoute) { - launchSingleTop = true - } + selectedTab = AppScreenTab.Settings + navController.navigate(DownloadsSettingsRoute) { + launchSingleTop = true } AppDeepLinkRepository.markConsumed(deepLink) } @@ -1131,7 +1085,7 @@ private fun MainAppContent( val targetResumePositionMs = if (startFromBeginning) 0L else (resumePositionMs ?: 0L) val targetResumeProgressFraction = if (startFromBeginning) null else resumeProgressFraction - if (!manualSelection && AppFeaturePolicy.downloadsEnabled) { + if (!manualSelection) { val downloadedItem = DownloadsRepository.findPlayableDownload( parentMetaId = parentMetaId, seasonNumber = seasonNumber, @@ -1388,31 +1342,12 @@ private fun MainAppContent( val isTabletLayout = maxWidth >= 768.dp val useNativeBottomTabs = liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady - val useDesktopSidebar = isDesktop && - isTabletLayout && - !useNativeBottomTabs && - desktopNavigationLayout == DesktopNavigationLayout.Sidebar - val useFloatingTopBar = isTabletLayout && !useNativeBottomTabs && !useDesktopSidebar - val topChromePadding = if (useFloatingTopBar) { - val statusBarPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() - max(statusBarPadding + 24.dp, 48.dp) + 64.dp - } else { - null - } val tabsRouteActive = currentBackStackEntry?.destination?.hasRoute() == true val onProfileSelected: (NuvioProfile) -> Unit = { profile -> profileSwitchLoading = true selectedTab = AppScreenTab.Home - coroutineScope.launch { - try { - ProfileRepository.switchToProfile(profile.profileIndex) - warmProfileBoundRepositories() - SyncManager.pullAllForProfile(profile.profileIndex) - delay(300) - } finally { - profileSwitchLoading = false - } - } + ProfileRepository.selectProfile(profile.profileIndex) + com.nuvio.app.core.sync.SyncManager.pullAllForProfile(profile.profileIndex) } Scaffold( @@ -1464,10 +1399,8 @@ private fun MainAppContent( AppTabHost( modifier = Modifier .fillMaxSize() - .padding(innerPadding) - .padding(start = if (useDesktopSidebar) DesktopSidebarCollapsedWidth else 0.dp), + .padding(innerPadding), selectedTab = selectedTab, - topChromePadding = topChromePadding, searchFocusRequestCount = searchFocusRequestCount, rootActionsEnabled = tabsRouteActive, homeScrollToTopRequests = homeScrollToTopRequests, @@ -1523,11 +1456,7 @@ private fun MainAppContent( onHomescreenSettingsClick = { navController.navigate(HomescreenSettingsRoute) }, onMetaScreenSettingsClick = { navController.navigate(MetaScreenSettingsRoute) }, onContinueWatchingSettingsClick = { navController.navigate(ContinueWatchingSettingsRoute) }, - onDownloadsSettingsClick = { - if (AppFeaturePolicy.downloadsEnabled) { - navController.navigate(DownloadsSettingsRoute) - } - }, + onDownloadsSettingsClick = { navController.navigate(DownloadsSettingsRoute) }, onAddonsSettingsClick = { navController.navigate(AddonsSettingsRoute) }, onPluginsSettingsClick = { if (AppFeaturePolicy.pluginsEnabled) { @@ -1563,14 +1492,7 @@ private fun MainAppContent( ) } - if (useDesktopSidebar) { - DesktopHoverSidebar( - selectedTab = selectedTab, - onTabSelected = ::handleRootTabClick, - onProfileSelected = onProfileSelected, - onAddProfileRequested = onSwitchProfile, - ) - } else if (useFloatingTopBar) { + if (isTabletLayout && !useNativeBottomTabs) { TabletFloatingTopBar( selectedTab = selectedTab, onTabSelected = ::handleRootTabClick, @@ -1803,7 +1725,10 @@ private fun MainAppContent( hasResolvedVideoId = true } - val playerSettings by PlayerSettingsRepository.uiState.collectAsStateWithLifecycle() + val playerSettings by remember { + PlayerSettingsRepository.ensureLoaded() + PlayerSettingsRepository.uiState + }.collectAsStateWithLifecycle() fun p2pSentinelUrl(infoHash: String, fileIdx: Int?): String = "torrent://$infoHash${fileIdx?.let { "?index=$it" }.orEmpty()}" @@ -2512,53 +2437,51 @@ private fun MainAppContent( onBack = onBack, ) } - if (AppFeaturePolicy.downloadsEnabled) { - composable { backStackEntry -> - val onBack = rememberGuardedPopBackStack( - navController = navController, - backStackEntry = backStackEntry, - ) - DownloadsScreen( - onBack = onBack, - onOpenDownload = { item -> - val sourceUrl = DownloadsRepository.playableLocalFileUri(item) ?: return@DownloadsScreen - val resumeEntry = item.videoId - .takeIf { it.isNotBlank() } - ?.let(WatchProgressRepository::progressForVideo) - ?.takeIf { it.isResumable } + composable { backStackEntry -> + val onBack = rememberGuardedPopBackStack( + navController = navController, + backStackEntry = backStackEntry, + ) + DownloadsScreen( + onBack = onBack, + onOpenDownload = { item -> + val sourceUrl = DownloadsRepository.playableLocalFileUri(item) ?: return@DownloadsScreen + val resumeEntry = item.videoId + .takeIf { it.isNotBlank() } + ?.let(WatchProgressRepository::progressForVideo) + ?.takeIf { it.isResumable } - val playerLaunch = PlayerLaunch( - title = item.title, - sourceUrl = sourceUrl, - sourceHeaders = emptyMap(), - sourceResponseHeaders = emptyMap(), - logo = item.logo, - poster = item.poster, - background = item.background, - seasonNumber = item.seasonNumber, - episodeNumber = item.episodeNumber, - episodeTitle = item.episodeTitle, - episodeThumbnail = item.episodeThumbnail, - streamTitle = item.streamTitle, - streamSubtitle = item.streamSubtitle, - providerName = item.providerName, - providerAddonId = item.providerAddonId, - contentType = item.contentType, - videoId = item.videoId, - parentMetaId = item.parentMetaId, - parentMetaType = item.parentMetaType, - initialPositionMs = resumeEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L, - initialProgressFraction = resumeEntry?.progressFraction?.takeIf { it > 0f }, - ) - if (playerSettingsUiState.externalPlayerEnabled) { - coroutineScope.launch { openExternalPlayback(playerLaunch) } - return@DownloadsScreen - } - val launchId = PlayerLaunchStore.put(playerLaunch) - navController.navigate(PlayerRoute(launchId = launchId)) - }, - ) - } + val playerLaunch = PlayerLaunch( + title = item.title, + sourceUrl = sourceUrl, + sourceHeaders = emptyMap(), + sourceResponseHeaders = emptyMap(), + logo = item.logo, + poster = item.poster, + background = item.background, + seasonNumber = item.seasonNumber, + episodeNumber = item.episodeNumber, + episodeTitle = item.episodeTitle, + episodeThumbnail = item.episodeThumbnail, + streamTitle = item.streamTitle, + streamSubtitle = item.streamSubtitle, + providerName = item.providerName, + providerAddonId = item.providerAddonId, + contentType = item.contentType, + videoId = item.videoId, + parentMetaId = item.parentMetaId, + parentMetaType = item.parentMetaType, + initialPositionMs = resumeEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L, + initialProgressFraction = resumeEntry?.progressFraction?.takeIf { it > 0f }, + ) + if (playerSettingsUiState.externalPlayerEnabled) { + coroutineScope.launch { openExternalPlayback(playerLaunch) } + return@DownloadsScreen + } + val launchId = PlayerLaunchStore.put(playerLaunch) + navController.navigate(PlayerRoute(launchId = launchId)) + }, + ) } composable { backStackEntry -> val onBack = rememberGuardedPopBackStack( @@ -2825,6 +2748,15 @@ private fun MainAppContent( AppLaunchOverlay(modifier = Modifier.fillMaxSize()) } + // Auto-dismiss profile switch overlay + if (profileSwitchLoading) { + LaunchedEffect(Unit) { + // Brief loading screen while home refreshes for the new profile + kotlinx.coroutines.delay(1200) + profileSwitchLoading = false + } + } + NuvioFloatingPrompt( visible = resumePromptItem != null, imageUrl = resumePromptItem?.poster ?: resumePromptItem?.imageUrl, @@ -2882,7 +2814,6 @@ private fun rememberGuardedPopBackStack( private fun AppTabHost( selectedTab: AppScreenTab, modifier: Modifier = Modifier, - topChromePadding: Dp? = null, searchFocusRequestCount: Int = 0, rootActionsEnabled: Boolean = true, homeScrollToTopRequests: Flow, @@ -2940,7 +2871,6 @@ private fun AppTabHost( AppScreenTab.Search -> { SearchScreen( modifier = Modifier.fillMaxSize(), - topChromePadding = topChromePadding, onPosterClick = onPosterClick, onPosterLongClick = onPosterLongClick, searchFocusRequestCount = searchFocusRequestCount, @@ -2951,7 +2881,6 @@ private fun AppTabHost( AppScreenTab.Library -> { LibraryScreen( modifier = Modifier.fillMaxSize(), - topChromePadding = topChromePadding, scrollToTopRequests = libraryScrollToTopRequests, onPosterClick = onLibraryPosterClick, onPosterLongClick = onLibraryPosterLongClick, @@ -2987,260 +2916,6 @@ private fun AppTabHost( } } -@Composable -private fun DesktopHoverSidebar( - selectedTab: AppScreenTab, - onTabSelected: (AppScreenTab) -> Unit, - onProfileSelected: (NuvioProfile) -> Unit, - onAddProfileRequested: () -> Unit, - modifier: Modifier = Modifier, -) { - val tokens = MaterialTheme.nuvio - val statusBarPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() - val profileState by ProfileRepository.state.collectAsStateWithLifecycle() - val avatars by AvatarRepository.avatars.collectAsStateWithLifecycle() - val activeProfile = profileState.activeProfile - val activeProfileName = activeProfile?.name ?: stringResource(Res.string.compose_nav_profile) - val hoverSource = remember { MutableInteractionSource() } - val hovered by hoverSource.collectIsHoveredAsState() - var profileStackVisible by remember { mutableStateOf(false) } - val sidebarExpanded = hovered || profileStackVisible - val profileTopPadding = statusBarPadding + 18.dp - fun selectTab(tab: AppScreenTab) { - profileStackVisible = false - onTabSelected(tab) - } - val sidebarWidth by animateDpAsState( - targetValue = if (sidebarExpanded) DesktopSidebarExpandedWidth else DesktopSidebarCollapsedWidth, - animationSpec = tween(durationMillis = 180), - label = "desktop_sidebar_width", - ) - - Surface( - modifier = modifier - .width(sidebarWidth) - .fillMaxHeight() - .hoverable(hoverSource) - .zIndex(NuvioTokens.Z.navigation), - color = tokens.colors.background, - contentColor = tokens.colors.textPrimary, - ) { - Box( - modifier = Modifier.fillMaxSize(), - ) { - Box( - modifier = Modifier - .align(Alignment.TopCenter) - .padding(top = profileTopPadding) - .fillMaxWidth() - .height(52.dp) - .padding(horizontal = 10.dp, vertical = 4.dp) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = { profileStackVisible = !profileStackVisible }, - ), - contentAlignment = Alignment.Center, - ) { - DesktopSidebarProfileTrigger( - profile = activeProfile, - avatars = avatars, - label = activeProfileName, - expanded = sidebarExpanded, - ) - } - - if (profileStackVisible) { - SidebarProfileSwitcherStack( - onProfileSelected = onProfileSelected, - onAddProfileRequested = onAddProfileRequested, - onDismissRequest = { profileStackVisible = false }, - modifier = Modifier - .align(Alignment.TopCenter) - .padding(top = profileTopPadding + 58.dp) - .width(DesktopSidebarExpandedContentWidth), - ) - } - - Column( - modifier = Modifier - .align(Alignment.Center) - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - DesktopSidebarItem( - label = stringResource(Res.string.compose_nav_home), - selected = selectedTab == AppScreenTab.Home, - expanded = sidebarExpanded, - onClick = { selectTab(AppScreenTab.Home) }, - ) { color -> - Icon( - imageVector = Icons.Filled.Home, - contentDescription = stringResource(Res.string.compose_nav_home), - modifier = Modifier.size(NuvioTokens.Space.s20), - tint = color, - ) - } - DesktopSidebarItem( - label = stringResource(Res.string.compose_nav_search), - selected = selectedTab == AppScreenTab.Search, - expanded = sidebarExpanded, - onClick = { selectTab(AppScreenTab.Search) }, - ) { color -> - Icon( - painter = painterResource(Res.drawable.sidebar_search), - contentDescription = stringResource(Res.string.compose_nav_search), - modifier = Modifier.size(NuvioTokens.Space.s20), - tint = color, - ) - } - DesktopSidebarItem( - label = stringResource(Res.string.compose_nav_library), - selected = selectedTab == AppScreenTab.Library, - expanded = sidebarExpanded, - onClick = { selectTab(AppScreenTab.Library) }, - ) { color -> - Icon( - painter = painterResource(Res.drawable.sidebar_library), - contentDescription = stringResource(Res.string.compose_nav_library), - modifier = Modifier.size(NuvioTokens.Space.s20), - tint = color, - ) - } - DesktopSidebarItem( - label = stringResource(Res.string.compose_settings_page_root), - selected = selectedTab == AppScreenTab.Settings, - expanded = sidebarExpanded, - onClick = { selectTab(AppScreenTab.Settings) }, - ) { color -> - Icon( - imageVector = Icons.Rounded.Settings, - contentDescription = stringResource(Res.string.compose_settings_page_root), - modifier = Modifier.size(NuvioTokens.Space.s20), - tint = color, - ) - } - } - } - } -} - -@Composable -private fun DesktopSidebarProfileTrigger( - profile: NuvioProfile?, - avatars: List, - label: String, - expanded: Boolean, -) { - val tokens = MaterialTheme.nuvio - - Surface( - modifier = Modifier.fillMaxSize(), - color = Color.Transparent, - shape = RoundedCornerShape(16.dp), - ) { - Row( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 10.dp), - horizontalArrangement = Arrangement.Start, - verticalAlignment = Alignment.CenterVertically, - ) { - Row( - modifier = Modifier.width( - if (expanded) DesktopSidebarExpandedContentWidth else DesktopSidebarIconSlotSize, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier.size(DesktopSidebarIconSlotSize), - contentAlignment = Alignment.Center, - ) { - ActiveProfileMiniAvatar( - profile = profile, - avatars = avatars, - selected = false, - size = 28, - ) - } - if (expanded) { - Spacer(modifier = Modifier.width(12.dp)) - Text( - text = label, - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.labelLarge, - color = tokens.colors.textPrimary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - } - } -} - -@Composable -private fun DesktopSidebarItem( - label: String, - selected: Boolean, - expanded: Boolean, - onClick: () -> Unit, - icon: @Composable (Color) -> Unit, -) { - val tokens = MaterialTheme.nuvio - val contentColor = if (selected) tokens.colors.textPrimary else tokens.colors.textMuted - val iconColor = if (selected) tokens.colors.onAccent else contentColor - - Surface( - modifier = Modifier - .fillMaxWidth() - .height(52.dp) - .padding(horizontal = 10.dp, vertical = 4.dp) - .clickable(onClick = onClick), - color = Color.Transparent, - shape = RoundedCornerShape(16.dp), - ) { - Row( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 10.dp), - horizontalArrangement = Arrangement.Start, - verticalAlignment = Alignment.CenterVertically, - ) { - Row( - modifier = Modifier.width( - if (expanded) DesktopSidebarExpandedContentWidth else DesktopSidebarIconSlotSize, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - Surface( - modifier = Modifier.size(DesktopSidebarIconSlotSize), - color = if (selected) tokens.colors.accent else Color.Transparent, - shape = RoundedCornerShape(14.dp), - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - icon(iconColor) - } - } - if (expanded) { - Spacer(modifier = Modifier.width(12.dp)) - Text( - text = label, - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.labelLarge, - color = contentColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - } - } -} - @Composable private fun TabletFloatingTopBar( selectedTab: AppScreenTab, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/Platform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/Platform.kt index 20d37440..5b1cae86 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/Platform.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/Platform.kt @@ -6,5 +6,4 @@ interface Platform { expect fun getPlatform(): Platform -internal expect val isIos: Boolean -internal expect val isDesktop: Boolean +internal expect val isIos: Boolean \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.kt index f231462b..8e62eb8e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.kt @@ -7,8 +7,6 @@ enum class TrailerPlaybackMode { expect object AppFeaturePolicy { val pluginsEnabled: Boolean - val downloadsEnabled: Boolean - val notificationsEnabled: Boolean val p2pEnabled: Boolean val trailerPlaybackMode: TrailerPlaybackMode val heroTrailerPlaybackSupported: Boolean diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppVersionPolicy.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppVersionPolicy.kt deleted file mode 100644 index 24e0a028..00000000 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppVersionPolicy.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.nuvio.app.core.build - -expect object AppVersionPolicy { - val displayVersionName: String - val displayVersionCode: Int - val basedOnVersionName: String? -} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt index 969acc3c..0702be8d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt @@ -1,7 +1,6 @@ package com.nuvio.app.core.sync import co.touchlab.kermit.Logger -import com.nuvio.app.isDesktop import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.network.SupabaseProvider @@ -79,17 +78,8 @@ object ProfileSettingsSync { @Volatile private var skipNextPushSignature: String? = null - @Volatile - private var preservedRemotePlayerSettings: JsonObject? = null - - @Volatile - private var preservedRemotePlayerSettingsProfileId: Int? = null - private var observeJob: Job? = null - private val syncPlayerSettings: Boolean - get() = !isDesktop - fun startObserving() { if (observeJob?.isActive == true) return ensureRepositoriesLoaded() @@ -101,13 +91,19 @@ object ProfileSettingsSync { return syncMutex.withLock { isServerSyncInFlight = true try { - val remoteJson = fetchRemoteSettingsJson(profileId) + val localBlob = exportSettingsBlob() + val localSignature = buildSignature(localBlob) + + val params = buildJsonObject { + put("p_profile_id", profileId) + put("p_platform", MOBILE_SYNC_PLATFORM) + } + val result = SupabaseProvider.client.postgrest.rpc("sync_pull_profile_settings_blob", params) + val response = result.decodeList().firstOrNull() + val remoteJson = response?.settingsJson if (remoteJson == null) { log.i { "pull(profileId=$profileId) — no remote settings blob found" } - clearPreservedRemotePlayerSettings(profileId) - val localBlob = exportSettingsBlob(profileId) - val localSignature = buildSignature(localBlob) if (localSignature != defaultSignature()) { pushToRemoteLocked(profileId, localBlob) } @@ -123,17 +119,13 @@ object ProfileSettingsSync { return@withLock false } - preserveRemotePlayerSettings(profileId, remoteBlob) - - val localBlob = exportSettingsBlob(profileId) - val localSignature = buildSignature(localBlob) val remoteSignature = buildSignature(remoteBlob) if (remoteSignature == localSignature) { log.d { "pull(profileId=$profileId) — remote matches local" } return@withLock false } - applyRemoteBlob(profileId, remoteBlob) + applyRemoteBlob(remoteBlob) skipNextPushSignature = currentObservedStateSignature() } finally { isApplyingRemoteBlob = false @@ -154,8 +146,7 @@ object ProfileSettingsSync { ensureRepositoriesLoaded() syncMutex.withLock { runCatching { - val profileId = ProfileRepository.activeProfileId - pushToRemoteLocked(profileId, exportSettingsBlob(profileId)) + pushToRemoteLocked(ProfileRepository.activeProfileId, exportSettingsBlob()) }.onFailure { error -> log.e(error) { "pushCurrentProfileToRemote() — FAILED" } } @@ -164,25 +155,23 @@ object ProfileSettingsSync { @OptIn(FlowPreview::class) private fun observeLocalChangesAndPush() { - val signatureFlows = buildList { - add(ThemeSettingsRepository.selectedTheme.map { "theme" }) - add(ThemeSettingsRepository.amoledEnabled.map { "amoled" }) - add(ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.map { "liquid_glass_tab_bar" }) - add(PosterCardStyleRepository.uiState.map { "poster_card_style" }) - if (syncPlayerSettings) { - add(PlayerSettingsRepository.uiState.map { "player" }) - } - add(StreamBadgeSettingsRepository.uiState.map { "stream_badges" }) - add(DebridSettingsRepository.uiState.map { "debrid" }) - add(TmdbSettingsRepository.uiState.map { "tmdb" }) - add(MdbListSettingsRepository.uiState.map { "mdblist" }) - add(MetaScreenSettingsRepository.uiState.map { "meta" }) - add(CollectionMobileSettingsRepository.uiState.map { "collection_mobile_settings" }) - add(ContinueWatchingPreferencesRepository.uiState.map { "continue_watching" }) - add(TraktSettingsRepository.uiState.map { "trakt_settings" }) - add(TraktCommentsSettings.enabled.map { "trakt_comments" }) - add(EpisodeReleaseNotificationsRepository.uiState.map { "episode_release_alerts" }) - } + val signatureFlows = listOf( + ThemeSettingsRepository.selectedTheme.map { "theme" }, + ThemeSettingsRepository.amoledEnabled.map { "amoled" }, + ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.map { "liquid_glass_tab_bar" }, + PosterCardStyleRepository.uiState.map { "poster_card_style" }, + PlayerSettingsRepository.uiState.map { "player" }, + StreamBadgeSettingsRepository.uiState.map { "stream_badges" }, + DebridSettingsRepository.uiState.map { "debrid" }, + TmdbSettingsRepository.uiState.map { "tmdb" }, + MdbListSettingsRepository.uiState.map { "mdblist" }, + MetaScreenSettingsRepository.uiState.map { "meta" }, + CollectionMobileSettingsRepository.uiState.map { "collection_mobile_settings" }, + ContinueWatchingPreferencesRepository.uiState.map { "continue_watching" }, + TraktSettingsRepository.uiState.map { "trakt_settings" }, + TraktCommentsSettings.enabled.map { "trakt_comments" }, + EpisodeReleaseNotificationsRepository.uiState.map { "episode_release_alerts" }, + ) observeJob = scope.launch { combine(signatureFlows) { currentObservedStateSignature() } @@ -203,23 +192,22 @@ object ProfileSettingsSync { } private suspend fun pushToRemoteLocked(profileId: Int, blob: MobileProfileSettingsBlob) { - val blobToPush = withPreservedDesktopPlayerSettings(profileId, blob) val params = buildJsonObject { put("p_profile_id", profileId) put("p_platform", MOBILE_SYNC_PLATFORM) - put("p_settings_json", json.encodeToJsonElement(MobileProfileSettingsBlob.serializer(), blobToPush)) + put("p_settings_json", json.encodeToJsonElement(MobileProfileSettingsBlob.serializer(), blob)) } SupabaseProvider.client.postgrest.rpc("sync_push_profile_settings_blob", params) log.d { "pushToRemoteLocked(profileId=$profileId) — success" } } - private fun exportSettingsBlob(profileId: Int = ProfileRepository.activeProfileId): MobileProfileSettingsBlob { + private fun exportSettingsBlob(): MobileProfileSettingsBlob { ensureRepositoriesLoaded() return MobileProfileSettingsBlob( features = MobileProfileSettingsFeatures( themeSettings = ThemeSettingsStorage.exportToSyncPayload(), posterCardStyleSettingsPayload = PosterCardStyleStorage.loadPayload().orEmpty().trim(), - playerSettings = exportPlayerSettingsPayload(profileId), + playerSettings = PlayerSettingsStorage.exportToSyncPayload(), streamBadgeSettings = StreamBadgeSettingsStorage.exportToSyncPayload(), debridSettings = DebridSettingsStorage.exportToSyncPayload(), tmdbSettings = TmdbSettingsStorage.exportToSyncPayload(), @@ -236,19 +224,15 @@ object ProfileSettingsSync { ) } - private fun applyRemoteBlob(profileId: Int, blob: MobileProfileSettingsBlob) { + private fun applyRemoteBlob(blob: MobileProfileSettingsBlob) { ThemeSettingsStorage.replaceFromSyncPayload(blob.features.themeSettings) ThemeSettingsRepository.onProfileChanged() PosterCardStyleStorage.savePayload(blob.features.posterCardStyleSettingsPayload) PosterCardStyleRepository.onProfileChanged() - if (syncPlayerSettings) { - PlayerSettingsStorage.replaceFromSyncPayload(blob.features.playerSettings) - PlayerSettingsRepository.onProfileChanged() - } else { - preserveRemotePlayerSettings(profileId, blob) - } + PlayerSettingsStorage.replaceFromSyncPayload(blob.features.playerSettings) + PlayerSettingsRepository.onProfileChanged() StreamBadgeSettingsStorage.replaceFromSyncPayload(blob.features.streamBadgeSettings) StreamBadgeSettingsRepository.onProfileChanged() @@ -284,9 +268,7 @@ object ProfileSettingsSync { private fun ensureRepositoriesLoaded() { ThemeSettingsRepository.ensureLoaded() PosterCardStyleRepository.ensureLoaded() - if (syncPlayerSettings) { - PlayerSettingsRepository.ensureLoaded() - } + PlayerSettingsRepository.ensureLoaded() StreamBadgeSettingsRepository.ensureLoaded() DebridSettingsRepository.ensureLoaded() TmdbSettingsRepository.ensureLoaded() @@ -305,90 +287,23 @@ object ProfileSettingsSync { private fun defaultSignature(): String = buildSignature(MobileProfileSettingsBlob()) - private fun currentObservedStateSignature(): String = buildList { - add("theme=${ThemeSettingsRepository.selectedTheme.value.name}") - add("amoled=${ThemeSettingsRepository.amoledEnabled.value}") - add("liquid_glass_tab_bar=${ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.value}") - add("poster_card_style=${PosterCardStyleRepository.uiState.value}") - if (syncPlayerSettings) { - add("player=${PlayerSettingsRepository.uiState.value}") - } - add("stream_badges=${StreamBadgeSettingsRepository.uiState.value}") - add("debrid=${DebridSettingsRepository.uiState.value}") - add("tmdb=${TmdbSettingsRepository.uiState.value}") - add("mdblist=${MdbListSettingsRepository.uiState.value}") - add("meta=${MetaScreenSettingsRepository.uiState.value}") - add("collection_mobile_settings=${CollectionMobileSettingsRepository.uiState.value}") - add("continue=${ContinueWatchingPreferencesRepository.uiState.value}") - add("trakt_settings=${TraktSettingsRepository.uiState.value}") - add("trakt_comments=${TraktCommentsSettings.enabled.value}") - add("episode_release_alerts=${EpisodeReleaseNotificationsRepository.uiState.value.isEnabled}") - }.joinToString(separator = "||") - - private fun exportPlayerSettingsPayload(profileId: Int): JsonObject = - if (syncPlayerSettings) { - PlayerSettingsStorage.exportToSyncPayload() - } else { - preservedRemotePlayerSettingsFor(profileId) ?: JsonObject(emptyMap()) - } - - private fun preserveRemotePlayerSettings(profileId: Int, blob: MobileProfileSettingsBlob) { - if (!syncPlayerSettings) { - preservedRemotePlayerSettingsProfileId = profileId - preservedRemotePlayerSettings = blob.features.playerSettings - } - } - - private fun clearPreservedRemotePlayerSettings(profileId: Int) { - if (!syncPlayerSettings) { - preservedRemotePlayerSettingsProfileId = profileId - preservedRemotePlayerSettings = null - } - } - - private fun preservedRemotePlayerSettingsFor(profileId: Int): JsonObject? = - preservedRemotePlayerSettings - ?.takeIf { preservedRemotePlayerSettingsProfileId == profileId } - - private suspend fun withPreservedDesktopPlayerSettings( - profileId: Int, - blob: MobileProfileSettingsBlob, - ): MobileProfileSettingsBlob { - if (syncPlayerSettings) return blob - - val remoteBlobResult = runCatching { - fetchRemoteSettingsJson(profileId) - ?.let { remoteJson -> - json.decodeFromJsonElement(MobileProfileSettingsBlob.serializer(), remoteJson) - } - } - val remotePlayerSettings = if (remoteBlobResult.isSuccess) { - remoteBlobResult.getOrNull()?.features?.playerSettings ?: JsonObject(emptyMap()) - } else { - val error = remoteBlobResult.exceptionOrNull() - if (error != null) { - log.e(error) { "pushToRemoteLocked(profileId=$profileId) — failed to preserve remote player settings" } - } - preservedRemotePlayerSettingsFor(profileId) ?: throw (error ?: IllegalStateException("Missing remote player settings")) - } - - preservedRemotePlayerSettingsProfileId = profileId - preservedRemotePlayerSettings = remotePlayerSettings - return blob.copy( - features = blob.features.copy( - playerSettings = remotePlayerSettings, - ), - ) - } - - private suspend fun fetchRemoteSettingsJson(profileId: Int): JsonObject? { - val params = buildJsonObject { - put("p_profile_id", profileId) - put("p_platform", MOBILE_SYNC_PLATFORM) - } - val result = SupabaseProvider.client.postgrest.rpc("sync_pull_profile_settings_blob", params) - return result.decodeList().firstOrNull()?.settingsJson - } + private fun currentObservedStateSignature(): String = listOf( + "theme=${ThemeSettingsRepository.selectedTheme.value.name}", + "amoled=${ThemeSettingsRepository.amoledEnabled.value}", + "liquid_glass_tab_bar=${ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.value}", + "poster_card_style=${PosterCardStyleRepository.uiState.value}", + "player=${PlayerSettingsRepository.uiState.value}", + "stream_badges=${StreamBadgeSettingsRepository.uiState.value}", + "debrid=${DebridSettingsRepository.uiState.value}", + "tmdb=${TmdbSettingsRepository.uiState.value}", + "mdblist=${MdbListSettingsRepository.uiState.value}", + "meta=${MetaScreenSettingsRepository.uiState.value}", + "collection_mobile_settings=${CollectionMobileSettingsRepository.uiState.value}", + "continue=${ContinueWatchingPreferencesRepository.uiState.value}", + "trakt_settings=${TraktSettingsRepository.uiState.value}", + "trakt_comments=${TraktCommentsSettings.enabled.value}", + "episode_release_alerts=${EpisodeReleaseNotificationsRepository.uiState.value.isEnabled}", + ).joinToString(separator = "||") } @Serializable diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.kt deleted file mode 100644 index c773a3a7..00000000 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.nuvio.app.core.ui - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.DefaultAlpha -import androidx.compose.ui.graphics.FilterQuality -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.layout.ContentScale -import coil3.compose.AsyncImagePainter - -internal enum class NuvioDesktopImageScaling { - Auto, - Disabled, -} - -@Composable -internal expect fun NuvioAsyncImage( - model: Any?, - contentDescription: String?, - modifier: Modifier = Modifier, - placeholder: Painter? = null, - error: Painter? = null, - fallback: Painter? = error, - onLoading: ((AsyncImagePainter.State.Loading) -> Unit)? = null, - onSuccess: ((AsyncImagePainter.State.Success) -> Unit)? = null, - onError: ((AsyncImagePainter.State.Error) -> Unit)? = null, - alignment: Alignment = Alignment.Center, - contentScale: ContentScale = ContentScale.Fit, - alpha: Float = DefaultAlpha, - colorFilter: ColorFilter? = null, - filterQuality: FilterQuality? = null, - clipToBounds: Boolean = true, - desktopImageScaling: NuvioDesktopImageScaling = NuvioDesktopImageScaling.Auto, -) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt index 6e841b46..f19d3b21 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage import com.nuvio.app.features.cloud.CloudLibraryContentType import com.nuvio.app.features.cloud.cloudLibraryDisplayArtworkUrl import com.nuvio.app.features.watchprogress.ContinueWatchingItem @@ -132,7 +133,7 @@ private fun ContinueWatchingSheetHeader( ) { val artwork = item.poster ?: item.imageUrl if (artwork != null) { - NuvioAsyncImage( + AsyncImage( model = cloudLibraryDisplayArtworkUrl(artwork), contentDescription = item.title, modifier = Modifier.matchParentSize(), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioFloatingPrompt.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioFloatingPrompt.kt index bf5c3dac..e6214c9e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioFloatingPrompt.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioFloatingPrompt.kt @@ -48,6 +48,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage import kotlinx.coroutines.delay import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.Res @@ -190,7 +191,7 @@ fun NuvioFloatingPrompt( contentAlignment = Alignment.Center, ) { if (imageUrl != null) { - NuvioAsyncImage( + AsyncImage( model = imageUrl, contentDescription = null, modifier = Modifier.matchParentSize(), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt index bf2e6149..6ad66ea4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.features.home.MetaPreview import kotlinx.coroutines.launch @@ -188,7 +189,7 @@ private fun PosterSheetHeader( contentAlignment = Alignment.Center, ) { if (item.poster != null) { - NuvioAsyncImage( + AsyncImage( model = item.poster, contentDescription = item.name, modifier = Modifier.matchParentSize(), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt index aa3932ac..fafed653 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt @@ -15,12 +15,8 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight @@ -32,18 +28,15 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.Dp import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.nuvio.app.isDesktop +import coil3.compose.AsyncImage import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.home_view_all import nuvio.composeapp.generated.resources.poster_logo_content_description import org.jetbrains.compose.resources.stringResource -import kotlin.math.abs enum class NuvioPosterShape { Poster, @@ -71,7 +64,6 @@ fun NuvioShelfSection( itemContent: @Composable (T) -> Unit, ) { val tokens = MaterialTheme.nuvio - val rowState = rememberLazyListState() Column( modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap + NuvioTokens.Space.s2), @@ -86,8 +78,6 @@ fun NuvioShelfSection( ) } LazyRow( - state = rowState, - modifier = Modifier.desktopShelfDragScroll(rowState), contentPadding = rowContentPadding, horizontalArrangement = Arrangement.spacedBy(itemSpacing), ) { @@ -107,47 +97,6 @@ fun NuvioShelfSection( } } -private fun Modifier.desktopShelfDragScroll( - state: LazyListState, -): Modifier { - if (!isDesktop) return this - - return pointerInput(state) { - awaitEachGesture { - val down = awaitFirstDown(pass = PointerEventPass.Initial) - var totalDx = 0f - var totalDy = 0f - var dragging = false - - while (true) { - val event = awaitPointerEvent(pass = PointerEventPass.Initial) - val change = event.changes.firstOrNull { it.id == down.id } ?: break - if (!change.pressed) break - - val delta = change.position - change.previousPosition - totalDx += delta.x - totalDy += delta.y - - if (!dragging) { - val horizontalDrag = - abs(totalDx) > viewConfiguration.touchSlop && abs(totalDx) > abs(totalDy) - val verticalDrag = - abs(totalDy) > viewConfiguration.touchSlop && abs(totalDy) > abs(totalDx) - - when { - verticalDrag -> break - horizontalDrag -> dragging = true - else -> continue - } - } - - state.dispatchRawDelta(-delta.x) - change.consume() - } - } - } -} - @Composable fun NuvioPosterCard( title: String, @@ -186,7 +135,7 @@ fun NuvioPosterCard( contentAlignment = Alignment.Center, ) { if (imageUrl != null) { - NuvioAsyncImage( + AsyncImage( model = imageUrl, contentDescription = title, modifier = Modifier.matchParentSize(), @@ -211,7 +160,7 @@ fun NuvioPosterCard( .padding(horizontal = NuvioTokens.Space.s10, vertical = NuvioTokens.Space.s10), ) { if (!bottomLeftLogoUrl.isNullOrBlank()) { - NuvioAsyncImage( + AsyncImage( model = bottomLeftLogoUrl, contentDescription = stringResource(Res.string.poster_logo_content_description, title), modifier = Modifier @@ -395,7 +344,6 @@ internal fun Modifier.posterCardClickable( onClick = { onClick?.invoke() }, onLongClick = onLongClick, ) - .secondaryClick(onLongClick) } else { this } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/SecondaryClick.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/SecondaryClick.kt deleted file mode 100644 index eea6d19b..00000000 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/SecondaryClick.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.nuvio.app.core.ui - -import androidx.compose.ui.Modifier - -internal expect fun Modifier.secondaryClick(onClick: (() -> Unit)?): Modifier diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt index 87abf388..7e12666d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt @@ -44,7 +44,7 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage +import coil3.compose.AsyncImage import com.nuvio.app.core.ui.NuvioIconActionButton import com.nuvio.app.core.ui.NuvioInfoBadge import com.nuvio.app.core.ui.NuvioInputField diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt index a251166e..e20cbbba 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt @@ -44,7 +44,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.network.NetworkCondition import com.nuvio.app.core.network.NetworkStatusRepository import com.nuvio.app.core.ui.NuvioNetworkOfflineCard -import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage +import coil3.compose.AsyncImage import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.core.ui.NuvioBackButton import com.nuvio.app.core.ui.NuvioPosterWatchedOverlay diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt index 008b1cf5..5881ce2e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt @@ -49,7 +49,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage +import coil3.compose.AsyncImage import com.nuvio.app.core.ui.NuvioPosterCard import com.nuvio.app.core.ui.NuvioPosterShape import com.nuvio.app.core.ui.NuvioScreenHeader diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index aef463c9..86e2cfa1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -60,7 +60,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage +import coil3.compose.AsyncImage import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.core.build.TrailerPlaybackMode import com.nuvio.app.core.network.NetworkCondition @@ -781,7 +781,6 @@ fun MetaDetailsScreen( BoxWithConstraints(modifier = Modifier.fillMaxSize()) { val isTablet = maxWidth >= 720.dp - val viewportHeight = maxHeight val contentHorizontalPadding = if (isTablet) 32.dp else 18.dp val contentMaxWidth = detailTabletContentMaxWidth(maxWidth, isTablet) val cinematicEnabled = metaScreenSettingsUiState.cinematicBackground && deferredMetaWorkAllowed @@ -816,7 +815,6 @@ fun MetaDetailsScreen( meta = meta, isTablet = isTablet, contentMaxWidth = contentMaxWidth, - viewportHeight = viewportHeight, scrollOffset = heroScrollOffset, onHeightChanged = { heroHeightPx = it }, heroTrailerSourceUrl = heroTrailerSourceUrl, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt index 9c968612..f209f7f2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt @@ -54,7 +54,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage +import coil3.compose.AsyncImage import coil3.compose.LocalPlatformContext import coil3.request.ImageRequest import com.nuvio.app.core.i18n.localizedShortMonthName diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt index 0803aacb..04abb4ca 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt @@ -43,7 +43,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage +import coil3.compose.AsyncImage import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource import com.nuvio.app.core.ui.landscapePosterHeightForWidth diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt index b9bd0b6d..7c3324ba 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt @@ -40,7 +40,6 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.nuvio.app.core.ui.AppIconResource import com.nuvio.app.core.ui.appIconPainter -import com.nuvio.app.core.ui.secondaryClick import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.action_play import org.jetbrains.compose.resources.stringResource @@ -108,7 +107,6 @@ fun DetailActionButtons( onLongClick = onPlayLongClick, role = Role.Button, ) - .secondaryClick(onPlayLongClick) .height(buttonHeight), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, @@ -251,8 +249,7 @@ private fun DetailIconAction( onClick = onClick, onLongClick = onLongClick, role = Role.Button, - ) - .secondaryClick(onLongClick), + ), contentAlignment = Alignment.Center, ) { Icon( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt index a6b498ce..306152a5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt @@ -29,7 +29,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage +import coil3.compose.AsyncImage import coil3.compose.LocalPlatformContext import coil3.request.ImageRequest import com.nuvio.app.features.details.MetaPerson diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt index 700aa41e..88a40c55 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt @@ -37,7 +37,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp -import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage +import coil3.compose.AsyncImage import com.nuvio.app.core.ui.NuvioBackButton import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.isIos diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt index 5754a250..0471ed11 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt @@ -43,8 +43,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.graphics.graphicsLayer -import com.nuvio.app.core.ui.NuvioDesktopImageScaling -import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage +import coil3.compose.AsyncImage import com.nuvio.app.features.details.MetaDetails import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -55,7 +54,6 @@ fun DetailHero( isTablet: Boolean = false, scrollOffset: Int = 0, contentMaxWidth: Dp = 560.dp, - viewportHeight: Dp = 0.dp, onHeightChanged: (Int) -> Unit = {}, heroTrailerSourceUrl: String? = null, heroTrailerSourceAudioUrl: String? = null, @@ -71,7 +69,7 @@ fun DetailHero( BoxWithConstraints( modifier = modifier.fillMaxWidth(), ) { - val heroHeight = detailHeroHeight(maxWidth, viewportHeight, isTablet) + val heroHeight = detailHeroHeight(maxWidth, isTablet) val trailerAlpha by animateFloatAsState( targetValue = if (heroTrailerReady) 1f else 0f, animationSpec = tween(durationMillis = 300), @@ -97,7 +95,6 @@ fun DetailHero( contentAlignment = Alignment.BottomCenter, ) { val imageUrl = meta.background ?: meta.poster - val backdropScale = if (isTablet) 1f else 1.08f if (imageUrl != null) { AsyncImage( model = imageUrl, @@ -106,12 +103,11 @@ fun DetailHero( .fillMaxSize() .graphicsLayer { translationY = scrollOffset * 0.5f - scaleX = backdropScale - scaleY = backdropScale + scaleX = 1.08f + scaleY = 1.08f }, - alignment = Alignment.Center, + alignment = if (isTablet) Alignment.TopCenter else Alignment.Center, contentScale = ContentScale.Crop, - desktopImageScaling = NuvioDesktopImageScaling.Disabled, ) } else { Box( @@ -131,8 +127,8 @@ fun DetailHero( .graphicsLayer { alpha = trailerAlpha translationY = scrollOffset * 0.5f - scaleX = backdropScale - scaleY = backdropScale + scaleX = 1.08f + scaleY = 1.08f }, onReady = onHeroTrailerReady, onEnded = onHeroTrailerEnded, @@ -237,13 +233,9 @@ fun DetailHero( } } -private fun detailHeroHeight(maxWidth: Dp, viewportHeight: Dp, isTablet: Boolean): Dp = +private fun detailHeroHeight(maxWidth: Dp, isTablet: Boolean): Dp = if (!isTablet) { (maxWidth * 1.33f).coerceIn(420.dp, 760.dp) } else { - val viewportLimit = viewportHeight - .takeIf { it > 0.dp } - ?.let { it * 0.72f } - ?: 1080.dp - minOf(maxWidth * 9f / 16f, viewportLimit).coerceIn(420.dp, 1080.dp) + (maxWidth * 0.42f).coerceIn(300.dp, 420.dp) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailProductionSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailProductionSection.kt index bdb1b6e8..b9308252 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailProductionSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailProductionSection.kt @@ -22,7 +22,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage +import coil3.compose.AsyncImage import com.nuvio.app.features.details.MetaCompany import com.nuvio.app.features.details.MetaDetails import nuvio.composeapp.generated.resources.* diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt index 82740e74..3601b1d0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt @@ -58,14 +58,13 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage +import coil3.compose.AsyncImage import co.touchlab.kermit.Logger import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.core.i18n.localizedSeasonEpisodeCode import com.nuvio.app.core.ui.NuvioAnimatedWatchedBadge import com.nuvio.app.core.ui.NuvioProgressBar -import com.nuvio.app.core.ui.secondaryClick import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaEpisodeCardStyle import com.nuvio.app.features.details.MetaVideo @@ -409,7 +408,6 @@ private fun SeasonTextChipScrollRow( ) { items(seasons, key = { season -> season }) { season -> val isSelected = season == currentSeason - val onSecondaryClick = onLongPress?.let { handler -> { handler(season) } } Box( modifier = Modifier .clip(RoundedCornerShape(sizing.seasonChipRadius)) @@ -422,9 +420,8 @@ private fun SeasonTextChipScrollRow( ) .combinedClickable( onClick = { onSelect(season) }, - onLongClick = onSecondaryClick, + onLongClick = onLongPress?.let { handler -> { handler(season) } }, ) - .secondaryClick(onSecondaryClick) .padding( horizontal = sizing.seasonChipHorizontalPadding, vertical = sizing.seasonChipVerticalPadding, @@ -511,8 +508,7 @@ private fun SeasonPosterButton( .combinedClickable( onClick = onClick, onLongClick = onLongClick, - ) - .secondaryClick(onLongClick), + ), verticalArrangement = Arrangement.spacedBy(8.dp), ) { Box( @@ -681,8 +677,7 @@ private fun EpisodeHorizontalCard( enabled = onClick != null || onLongPress != null, onClick = { onClick?.invoke() }, onLongClick = onLongPress, - ) - .secondaryClick(onLongPress), + ), ) { val imageUrl = video.thumbnail ?: fallbackImage val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched @@ -1050,8 +1045,7 @@ private fun EpisodeListCard( enabled = onClick != null || onLongPress != null, onClick = { onClick?.invoke() }, onLongClick = onLongPress, - ) - .secondaryClick(onLongPress), + ), ) { Row( modifier = Modifier.fillMaxSize(), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt index f5226e9b..e9ef5fa8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt @@ -36,7 +36,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage +import coil3.compose.AsyncImage import com.nuvio.app.features.details.MetaTrailer import nuvio.composeapp.generated.resources.* import nuvio.composeapp.generated.resources.detail_tab_trailer diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index 2f052064..0d90dfad 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -14,7 +14,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.nuvio.app.isDesktop import com.nuvio.app.core.network.NetworkCondition import com.nuvio.app.core.network.NetworkStatusRepository import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding @@ -105,21 +104,19 @@ fun HomeScreen( onFirstCatalogRendered: (() -> Unit)? = null, ) { LaunchedEffect(Unit) { - withContext(Dispatchers.Default) { - AddonRepository.initialize() - CollectionRepository.initialize() - ContinueWatchingPreferencesRepository.ensureLoaded() - HomeCatalogSettingsRepository.snapshot() - TraktSettingsRepository.ensureLoaded() - TraktAuthRepository.ensureLoaded() - WatchedRepository.ensureLoaded() - WatchProgressRepository.ensureLoaded() - } + AddonRepository.initialize() + CollectionRepository.initialize() + ContinueWatchingPreferencesRepository.ensureLoaded() + WatchedRepository.ensureLoaded() + WatchProgressRepository.ensureLoaded() } val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle() val homeUiState by HomeRepository.uiState.collectAsStateWithLifecycle() - val homeSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle() + val homeSettingsUiState by remember { + HomeCatalogSettingsRepository.snapshot() + HomeCatalogSettingsRepository.uiState + }.collectAsStateWithLifecycle() val homeListState = rememberLazyListState() val collections by CollectionRepository.collections.collectAsStateWithLifecycle() val continueWatchingPreferences by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle() @@ -127,8 +124,14 @@ fun HomeScreen( val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle() val cloudLibraryUiState by CloudLibraryRepository.uiState.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() - val traktSettingsUiState by TraktSettingsRepository.uiState.collectAsStateWithLifecycle() - val isTraktAuthenticated by TraktAuthRepository.isAuthenticated.collectAsStateWithLifecycle() + val traktSettingsUiState by remember { + TraktSettingsRepository.ensureLoaded() + TraktSettingsRepository.uiState + }.collectAsStateWithLifecycle() + val isTraktAuthenticated by remember { + TraktAuthRepository.ensureLoaded() + TraktAuthRepository.isAuthenticated + }.collectAsStateWithLifecycle() var observedOfflineState by remember { mutableStateOf(false) } LaunchedEffect(scrollToTopRequests) { @@ -647,7 +650,6 @@ fun HomeScreen( modifier = Modifier, viewportHeight = maxHeight, mobileBelowSectionHeightHint = mobileHeroBelowSectionHeightHint, - sectionPadding = if (isDesktop) homeSectionPadding else null, ) homeUiState.heroItems.isNotEmpty() -> HomeHeroSection( @@ -655,7 +657,6 @@ fun HomeScreen( modifier = Modifier, viewportHeight = maxHeight, mobileBelowSectionHeightHint = mobileHeroBelowSectionHeightHint, - sectionPadding = if (isDesktop) homeSectionPadding else null, listState = homeListState, onItemClick = onPosterClick, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt index 9ad94cb4..99f36172 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt @@ -45,7 +45,7 @@ import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage +import coil3.compose.AsyncImage import com.nuvio.app.core.ui.NuvioProgressBar import com.nuvio.app.core.ui.NuvioShelfSection import com.nuvio.app.core.ui.PosterLandscapeAspectRatio @@ -53,7 +53,6 @@ import com.nuvio.app.core.ui.landscapePosterHeightForWidth import com.nuvio.app.core.ui.landscapePosterWidth import com.nuvio.app.core.ui.posterCardClickable import com.nuvio.app.core.ui.rememberPosterCardStyleUiState -import com.nuvio.app.core.ui.secondaryClick import com.nuvio.app.features.cloud.CloudLibraryContentType import com.nuvio.app.features.cloud.cloudLibraryDisplayArtworkUrl import com.nuvio.app.features.home.HomeCatalogSettingsRepository @@ -804,8 +803,7 @@ private fun ContinueWatchingWideCard( enabled = onClick != null || onLongClick != null, onClick = { onClick?.invoke() }, onLongClick = onLongClick, - ) - .secondaryClick(onLongClick), + ), ) { val shouldBlurArtwork = blurNextUp && useEpisodeThumbnails && item.isNextUp val artworkUrl = item.continueWatchingArtworkUrl(useEpisodeThumbnails) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt index 734bea9d..d09907ec 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -48,9 +47,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.nuvio.app.isDesktop -import com.nuvio.app.core.ui.NuvioDesktopImageScaling -import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage +import coil3.compose.AsyncImage import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.features.home.MetaPreview import kotlinx.coroutines.CoroutineScope @@ -89,7 +86,6 @@ fun HomeHeroSection( modifier: Modifier = Modifier, viewportHeight: Dp? = null, mobileBelowSectionHeightHint: Dp? = null, - sectionPadding: Dp? = null, listState: LazyListState? = null, onItemClick: ((MetaPreview) -> Unit)? = null, ) { @@ -112,7 +108,6 @@ fun HomeHeroSection( maxWidthDp = maxWidth.value, viewportHeightDp = viewportHeight?.value, mobileBelowSectionHeightHintDp = mobileBelowSectionHeightHint?.value, - preferDesktopLayout = isDesktop, ) val heroWidthPx = with(LocalDensity.current) { maxWidth.toPx() } val heroHeightPx = with(LocalDensity.current) { layout.heroHeight.toPx() } @@ -168,32 +163,136 @@ fun HomeHeroSection( Box(modifier = Modifier.fillMaxSize()) } - if (isDesktop) { - DesktopHomeHeroFrame( - items = items, - visiblePages = visiblePages, - layout = layout, - heroWidthPx = heroWidthPx, - heroScrollScale = heroScrollScale, - heroScrollTranslationY = heroScrollTranslationY, - contentHorizontalPadding = sectionPadding ?: layout.contentHorizontalPadding, - pagerState = pagerState, - coroutineScope = coroutineScope, - onItemClick = onItemClick, + Box( + modifier = Modifier.fillMaxSize(), + ) { + visiblePages.forEach { layer -> + AsyncImage( + model = items[layer.page].banner ?: items[layer.page].poster, + contentDescription = items[layer.page].name, + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + alpha = layer.visibility + translationX = -layer.offset * heroWidthPx * HERO_BACKGROUND_PARALLAX + translationY = heroScrollTranslationY + scaleX = HERO_BACKGROUND_SCALE * heroScrollScale + scaleY = HERO_BACKGROUND_SCALE * heroScrollScale + }, + alignment = if (layout.isTablet) Alignment.TopCenter else Alignment.Center, + contentScale = ContentScale.Crop, + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + MaterialTheme.colorScheme.background.copy(alpha = 0.02f), + MaterialTheme.colorScheme.background.copy(alpha = 0.12f), + MaterialTheme.colorScheme.background.copy(alpha = 0.34f), + MaterialTheme.colorScheme.background.copy(alpha = 0.78f), + ), + ), + ), ) - } else { - DefaultHomeHeroFrame( - items = items, - visiblePages = visiblePages, - currentItem = currentItem, - layout = layout, - heroWidthPx = heroWidthPx, - heroScrollScale = heroScrollScale, - heroScrollTranslationY = heroScrollTranslationY, - pagerState = pagerState, - coroutineScope = coroutineScope, - onItemClick = onItemClick, + + Box( + modifier = Modifier + .fillMaxWidth() + .height(layout.bottomFadeHeight) + .align(Alignment.BottomCenter) + .background( + Brush.verticalGradient( + colors = listOf( + MaterialTheme.colorScheme.background.copy(alpha = 0f), + MaterialTheme.colorScheme.background, + ), + ), + ), ) + + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding( + horizontal = layout.contentHorizontalPadding, + vertical = layout.contentVerticalPadding, + ), + horizontalAlignment = if (layout.isTablet) Alignment.Start else Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .fillMaxWidth(layout.contentWidthFraction) + .widthIn(max = layout.contentMaxWidth), + contentAlignment = if (layout.isTablet) Alignment.CenterStart else Alignment.Center, + ) { + visiblePages.forEach { layer -> + Box( + modifier = Modifier.graphicsLayer { + alpha = layer.visibility + translationX = -layer.offset * heroWidthPx * HERO_CONTENT_PARALLAX + }, + ) { + HeroContentBlock( + item = items[layer.page], + layout = layout, + onItemClick = onItemClick, + ) + } + } + } + + if (!layout.isTablet) { + Spacer(modifier = Modifier.height(14.dp)) + Surface( + modifier = Modifier + .clickable(enabled = onItemClick != null) { + onItemClick?.invoke(currentItem) + }, + color = MaterialTheme.colorScheme.onBackground, + contentColor = MaterialTheme.colorScheme.background, + shape = RoundedCornerShape(40.dp), + ) { + Text( + text = stringResource(Res.string.home_view_details), + modifier = Modifier.padding(horizontal = 28.dp, vertical = 12.dp), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + } + + if (items.size > 1) { + Spacer(modifier = Modifier.height(if (layout.isTablet) 14.dp else 12.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + items.forEachIndexed { index, _ -> + val activeFraction = heroPageVisibility(pagerState, index) + Box( + modifier = Modifier + .clickable { + coroutineScope.launch { + pagerState.animateScrollToPage(index) + } + } + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onBackground) + .graphicsLayer { + alpha = 0.35f + (0.57f * activeFraction) + } + .width(8.dp + (24.dp * activeFraction)) + .height(8.dp), + ) + } + } + } + } } } } @@ -205,284 +304,6 @@ private data class HeroPageLayer( val offset: Float, ) -@Composable -private fun DefaultHomeHeroFrame( - items: List, - visiblePages: List, - currentItem: MetaPreview, - layout: HomeHeroLayout, - heroWidthPx: Float, - heroScrollScale: Float, - heroScrollTranslationY: Float, - pagerState: PagerState, - coroutineScope: CoroutineScope, - onItemClick: ((MetaPreview) -> Unit)?, -) { - Box( - modifier = Modifier.fillMaxSize(), - ) { - visiblePages.forEach { layer -> - AsyncImage( - model = items[layer.page].banner ?: items[layer.page].poster, - contentDescription = items[layer.page].name, - modifier = Modifier - .fillMaxSize() - .graphicsLayer { - alpha = layer.visibility - translationX = -layer.offset * heroWidthPx * HERO_BACKGROUND_PARALLAX - translationY = heroScrollTranslationY - scaleX = HERO_BACKGROUND_SCALE * heroScrollScale - scaleY = HERO_BACKGROUND_SCALE * heroScrollScale - }, - alignment = if (layout.isTablet) Alignment.TopCenter else Alignment.Center, - contentScale = ContentScale.Crop, - desktopImageScaling = NuvioDesktopImageScaling.Disabled, - ) - } - - Box( - modifier = Modifier - .fillMaxSize() - .background( - Brush.verticalGradient( - colors = listOf( - MaterialTheme.colorScheme.background.copy(alpha = 0.02f), - MaterialTheme.colorScheme.background.copy(alpha = 0.12f), - MaterialTheme.colorScheme.background.copy(alpha = 0.34f), - MaterialTheme.colorScheme.background.copy(alpha = 0.78f), - ), - ), - ), - ) - - Box( - modifier = Modifier - .fillMaxWidth() - .height(layout.bottomFadeHeight) - .align(Alignment.BottomCenter) - .background( - Brush.verticalGradient( - colors = listOf( - MaterialTheme.colorScheme.background.copy(alpha = 0f), - MaterialTheme.colorScheme.background, - ), - ), - ), - ) - - Column( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .padding( - horizontal = layout.contentHorizontalPadding, - vertical = layout.contentVerticalPadding, - ), - horizontalAlignment = if (layout.isTablet) Alignment.Start else Alignment.CenterHorizontally, - ) { - Box( - modifier = Modifier - .fillMaxWidth(layout.contentWidthFraction) - .widthIn(max = layout.contentMaxWidth), - contentAlignment = if (layout.isTablet) Alignment.CenterStart else Alignment.Center, - ) { - visiblePages.forEach { layer -> - Box( - modifier = Modifier.graphicsLayer { - alpha = layer.visibility - translationX = -layer.offset * heroWidthPx * HERO_CONTENT_PARALLAX - }, - ) { - HeroContentBlock( - item = items[layer.page], - layout = layout, - onItemClick = onItemClick, - ) - } - } - } - - if (!layout.isTablet) { - Spacer(modifier = Modifier.height(14.dp)) - Surface( - modifier = Modifier - .clickable(enabled = onItemClick != null) { - onItemClick?.invoke(currentItem) - }, - color = MaterialTheme.colorScheme.onBackground, - contentColor = MaterialTheme.colorScheme.background, - shape = RoundedCornerShape(40.dp), - ) { - Text( - text = stringResource(Res.string.home_view_details), - modifier = Modifier.padding(horizontal = 28.dp, vertical = 12.dp), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - } - } - - HeroPageIndicatorRow( - itemCount = items.size, - pagerState = pagerState, - coroutineScope = coroutineScope, - modifier = Modifier.padding(top = if (layout.isTablet) 14.dp else 12.dp), - ) - } - } -} - -@Composable -private fun DesktopHomeHeroFrame( - items: List, - visiblePages: List, - layout: HomeHeroLayout, - heroWidthPx: Float, - heroScrollScale: Float, - heroScrollTranslationY: Float, - contentHorizontalPadding: Dp, - pagerState: PagerState, - coroutineScope: CoroutineScope, - onItemClick: ((MetaPreview) -> Unit)?, -) { - val backgroundColor = MaterialTheme.colorScheme.background - - Box( - modifier = Modifier - .fillMaxSize() - .background(backgroundColor), - ) { - visiblePages.forEach { layer -> - Box( - modifier = Modifier - .align(Alignment.CenterEnd) - .fillMaxHeight() - .fillMaxWidth(0.64f) - .graphicsLayer { - alpha = layer.visibility - translationX = -layer.offset * heroWidthPx * HERO_BACKGROUND_PARALLAX - translationY = heroScrollTranslationY - scaleX = 1.02f * heroScrollScale - scaleY = 1.02f * heroScrollScale - }, - ) { - AsyncImage( - model = items[layer.page].banner ?: items[layer.page].poster, - contentDescription = items[layer.page].name, - modifier = Modifier.fillMaxSize(), - alignment = Alignment.Center, - contentScale = ContentScale.Crop, - desktopImageScaling = NuvioDesktopImageScaling.Disabled, - ) - } - } - - Box( - modifier = Modifier - .fillMaxSize() - .background( - Brush.horizontalGradient( - colorStops = arrayOf( - 0f to backgroundColor, - 0.34f to backgroundColor, - 0.58f to backgroundColor.copy(alpha = 0.78f), - 0.78f to backgroundColor.copy(alpha = 0.18f), - 1f to backgroundColor.copy(alpha = 0f), - ), - ), - ), - ) - - Box( - modifier = Modifier - .fillMaxWidth() - .height(layout.bottomFadeHeight) - .align(Alignment.BottomCenter) - .background( - Brush.verticalGradient( - colors = listOf( - backgroundColor.copy(alpha = 0f), - backgroundColor, - ), - ), - ), - ) - - Box( - modifier = Modifier - .align(Alignment.CenterStart) - .padding(start = contentHorizontalPadding, end = contentHorizontalPadding) - .fillMaxWidth(layout.contentWidthFraction) - .widthIn(max = layout.contentMaxWidth), - contentAlignment = Alignment.CenterStart, - ) { - visiblePages.forEach { layer -> - Box( - modifier = Modifier - .fillMaxWidth() - .graphicsLayer { - alpha = layer.visibility - translationX = -layer.offset * heroWidthPx * HERO_CONTENT_PARALLAX - }, - ) { - DesktopHeroContentBlock( - item = items[layer.page], - layout = layout, - onItemClick = onItemClick, - ) - } - } - } - - HeroPageIndicatorRow( - itemCount = items.size, - pagerState = pagerState, - coroutineScope = coroutineScope, - modifier = Modifier - .align(Alignment.BottomStart) - .padding( - start = contentHorizontalPadding, - bottom = layout.contentVerticalPadding, - ), - ) - } -} - -@Composable -private fun HeroPageIndicatorRow( - itemCount: Int, - pagerState: PagerState, - coroutineScope: CoroutineScope, - modifier: Modifier = Modifier, -) { - if (itemCount <= 1) return - - Row( - modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - repeat(itemCount) { index -> - val activeFraction = heroPageVisibility(pagerState, index) - Box( - modifier = Modifier - .clickable { - coroutineScope.launch { - pagerState.animateScrollToPage(index) - } - } - .clip(CircleShape) - .background(MaterialTheme.colorScheme.onBackground) - .graphicsLayer { - alpha = 0.35f + (0.57f * activeFraction) - } - .width(8.dp + (24.dp * activeFraction)) - .height(8.dp), - ) - } - } -} - private fun heroPageOffset( pagerState: PagerState, page: Int, @@ -510,7 +331,6 @@ fun HomeHeroReservedSpace( maxWidthDp = maxWidth.value, viewportHeightDp = viewportHeight?.value, mobileBelowSectionHeightHintDp = mobileBelowSectionHeightHint?.value, - preferDesktopLayout = isDesktop, ) Spacer( @@ -588,97 +408,6 @@ private fun HeroContentBlock( } } -@Composable -private fun DesktopHeroContentBlock( - item: MetaPreview, - layout: HomeHeroLayout, - onItemClick: ((MetaPreview) -> Unit)?, -) { - Column( - modifier = Modifier - .fillMaxWidth() - .clickable(enabled = onItemClick != null) { - onItemClick?.invoke(item) - }, - horizontalAlignment = Alignment.Start, - ) { - if (item.logo != null) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(desktopHeroLogoSlotHeight(layout)), - contentAlignment = Alignment.CenterStart, - ) { - AsyncImage( - model = item.logo, - contentDescription = item.name, - modifier = Modifier - .fillMaxWidth(desktopHeroLogoWidthFraction(layout)) - .fillMaxHeight(), - alignment = Alignment.CenterStart, - contentScale = ContentScale.Fit, - clipToBounds = false, - ) - } - } else { - Text( - text = item.name, - modifier = Modifier.fillMaxWidth(), - style = MaterialTheme.typography.displayMedium, - color = MaterialTheme.colorScheme.onBackground, - fontWeight = FontWeight.Black, - textAlign = TextAlign.Start, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - - val genreText = desktopHeroGenreText(item) - if (genreText.isNotBlank()) { - Spacer(modifier = Modifier.height(14.dp)) - Text( - text = genreText, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.76f), - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - - item.description?.takeIf { it.isNotBlank() }?.let { description -> - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = description, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.82f), - maxLines = 4, - overflow = TextOverflow.Ellipsis, - ) - } - } -} - -private fun desktopHeroLogoWidthFraction(layout: HomeHeroLayout): Float = - when { - layout.contentMaxWidth >= 640.dp -> 0.74f - layout.contentMaxWidth >= 520.dp -> 0.74f - else -> 0.8f - } - -private fun desktopHeroLogoSlotHeight(layout: HomeHeroLayout): Dp = - when { - layout.contentMaxWidth >= 640.dp -> 156.dp - layout.contentMaxWidth >= 520.dp -> 136.dp - else -> 104.dp - } - -private fun desktopHeroGenreText(item: MetaPreview): String = - item.genres - .take(3) - .joinToString(" • ") - .ifBlank { item.type.replaceFirstChar(Char::uppercase) } - @Composable private fun HeroMetaText(text: String) { Text( @@ -695,7 +424,6 @@ internal fun homeHeroLayout( maxWidthDp: Float, viewportHeightDp: Float? = null, mobileBelowSectionHeightHintDp: Float? = null, - preferDesktopLayout: Boolean = false, ): HomeHeroLayout = when { maxWidthDp >= 1200f -> HomeHeroLayout( @@ -728,16 +456,6 @@ internal fun homeHeroLayout( bottomFadeHeight = 170.dp, logoWidthFraction = 0.54f, ) - preferDesktopLayout -> HomeHeroLayout( - isTablet = true, - heroHeight = (maxWidthDp * 0.68f).dp.coerceIn(300.dp, 360.dp), - contentMaxWidth = 360.dp, - contentWidthFraction = 0.56f, - contentHorizontalPadding = 16.dp, - contentVerticalPadding = 18.dp, - bottomFadeHeight = 150.dp, - logoWidthFraction = 0.64f, - ) else -> HomeHeroLayout( isTablet = false, heroHeight = mobileHeroHeight( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeSkeletonLoading.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeSkeletonLoading.kt index a2bcd03c..d95b26bc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeSkeletonLoading.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeSkeletonLoading.kt @@ -32,7 +32,6 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.nuvio.app.isDesktop import com.nuvio.app.core.ui.landscapePosterHeightForWidth import com.nuvio.app.core.ui.landscapePosterWidth import com.nuvio.app.core.ui.rememberPosterCardStyleUiState @@ -66,7 +65,6 @@ fun HomeSkeletonHero( modifier: Modifier = Modifier, viewportHeight: Dp? = null, mobileBelowSectionHeightHint: Dp? = null, - sectionPadding: Dp? = null, ) { val brush = rememberHomeSkeletonBrush() @@ -79,10 +77,8 @@ fun HomeSkeletonHero( maxWidthDp = maxWidth.value, viewportHeightDp = viewportHeight?.value, mobileBelowSectionHeightHintDp = mobileBelowSectionHeightHint?.value, - preferDesktopLayout = isDesktop, ) val containerWidth = maxWidth - val contentHorizontalPadding = sectionPadding ?: layout.contentHorizontalPadding Box( modifier = Modifier @@ -125,7 +121,7 @@ fun HomeSkeletonHero( .align(Alignment.BottomCenter) .fillMaxWidth() .padding( - horizontal = contentHorizontalPadding, + horizontal = layout.contentHorizontalPadding, vertical = layout.contentVerticalPadding, ), horizontalAlignment = if (layout.isTablet) Alignment.Start else Alignment.CenterHorizontally, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt index 4826efa0..c64c449e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt @@ -88,7 +88,6 @@ import org.jetbrains.compose.resources.stringResource @Composable fun LibraryScreen( modifier: Modifier = Modifier, - topChromePadding: Dp? = null, scrollToTopRequests: Flow = emptyFlow(), onPosterClick: ((LibraryItem) -> Unit)? = null, onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)? = null, @@ -175,7 +174,6 @@ fun LibraryScreen( NuvioScreen( modifier = modifier, horizontalPadding = 0.dp, - topPadding = if (topChromePadding != null) 0.dp else null, listState = listState, ) { stickyHeader { @@ -198,7 +196,6 @@ fun LibraryScreen( stringResource(Res.string.library_title) }, modifier = Modifier.padding(horizontal = 16.dp), - topPadding = topChromePadding, ) LibrarySourceSwitch( selectedMode = sourceMode, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt index cdd15b8b..ae78fb8d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt @@ -23,233 +23,6 @@ interface PlayerEngineController { fun configureIosVideoOutput(settings: PlayerSettingsUiState) {} } -enum class PlayerControlsAction { - ToggleChrome, - RevealLockedOverlay, - Back, - TogglePlayback, - KeyboardTogglePlayback, - SeekBack, - KeyboardSeekBack, - SeekForward, - KeyboardSeekForward, - ResizeMode, - Speed, - Subtitles, - Audio, - Sources, - Episodes, - OpenExternalPlayer, - SubmitIntro, - LockToggle, - VideoSettings, - DoubleTapSeekBack, - DoubleTapSeekForward, -} - -data class PlayerControlsState( - val title: String = "", - val episodeText: String = "", - val streamTitle: String = "", - val providerName: String = "", - val pauseOverlayWatchingLabel: String = "You're watching", - val pauseOverlayLogo: String? = null, - val pauseOverlayEpisodeInfo: String = "", - val pauseOverlayEpisodeTitle: String = "", - val pauseOverlayDescription: String = "", - val resizeModeLabel: String = "Fit", - val playbackSpeedLabel: String = "1x", - val subtitlesLabel: String = "Subs", - val audioLabel: String = "Audio", - val sourcesLabel: String = "Sources", - val episodesLabel: String = "Episodes", - val externalPlayerLabel: String = "External", - val playLabel: String = "Play", - val pauseLabel: String = "Pause", - val closeLabel: String = "Close player", - val lockLabel: String = "Lock player controls", - val unlockLabel: String = "Unlock player controls", - val submitIntroLabel: String = "Submit Intro", - val videoSettingsLabel: String = "Video settings", - val tapToUnlockLabel: String = "Tap to unlock", - val playbackErrorTitle: String = "Playback error", - val playbackErrorMessage: String = "", - val playbackErrorActionLabel: String = "Go back", - val sourcesPanelTitle: String = "Sources", - val episodesPanelTitle: String = "Episodes", - val streamsPanelTitle: String = "Streams", - val allFilterLabel: String = "All", - val reloadLabel: String = "Reload", - val backLabel: String = "Back", - val panelCloseLabel: String = "Close", - val cancelLabel: String = "Cancel", - val playingLabel: String = "Playing", - val noStreamsLabel: String = "No streams found", - val noEpisodesLabel: String = "No episodes available", - val submitIntroPanelTitle: String = "Submit Timestamps", - val submitIntroSegmentTypeLabel: String = "SEGMENT TYPE", - val submitIntroSegmentIntroLabel: String = "Intro", - val submitIntroSegmentRecapLabel: String = "Recap", - val submitIntroSegmentOutroLabel: String = "Outro", - val submitIntroStartTimeLabel: String = "START TIME (MM:SS)", - val submitIntroEndTimeLabel: String = "END TIME (MM:SS)", - val submitIntroCaptureLabel: String = "Capture", - val submitIntroSubmitLabel: String = "Submit", - val p2pConsentTitle: String = "P2P Streaming", - val p2pConsentBody: String = "", - val p2pConsentEnableLabel: String = "Enable P2P", - val p2pConsentCancelLabel: String = "Cancel", - val subtitlesPanelTitle: String = "Subtitles", - val subtitleBuiltInTabLabel: String = "Built-in", - val subtitleAddonsTabLabel: String = "Addons", - val subtitleStyleTabLabel: String = "Style", - val noneLabel: String = "None", - val fetchSubtitlesLabel: String = "Tap to fetch subtitles", - val subtitleDelayLabel: String = "Subtitle Delay", - val resetLabel: String = "Reset", - val autoSyncLabel: String = "Auto Sync", - val reloadSmallLabel: String = "Reload", - val captureLineLabel: String = "Capture", - val selectAddonSubtitleFirstLabel: String = "Select an addon subtitle first", - val loadingSubtitleLinesLabel: String = "Loading subtitle lines...", - val fontSizeLabel: String = "Font Size", - val outlineLabel: String = "Outline", - val boldLabel: String = "Bold", - val bottomOffsetLabel: String = "Bottom Offset", - val colorLabel: String = "Color", - val textOpacityLabel: String = "Text Opacity", - val outlineColorLabel: String = "Outline Color", - val resetDefaultsLabel: String = "Reset Defaults", - val onLabel: String = "On", - val offLabel: String = "Off", - val themeAccentColor: String = "#2f6fed", - val themeAccentStrongColor: String = "#3c7bff", - val themeOnAccentColor: String = "#ffffff", - val themeFocusColor: String = "#9ecaff", - val themeSelectedSurfaceColor: String = "#26384f", - val themeSelectedSurfaceHoverColor: String = "#2d4565", - val themeSelectedRingColor: String = "rgba(47, 111, 237, .35)", - val themeTimelineFillColor: String = "#ffffff", - val themeTimelineTrackColor: String = "rgba(255, 255, 255, .28)", - val themeBufferingColor: String = "#ffffff", - val themeBufferingTrackColor: String = "rgba(255, 255, 255, .28)", - val themeControlForegroundColor: String = "#ffffff", - val isPlaying: Boolean = false, - val isLoading: Boolean = false, - val isLocked: Boolean = false, - val lockedOverlayVisible: Boolean = false, - val controlsVisible: Boolean = true, - val parentalWarnings: List = emptyList(), - val showParentalGuide: Boolean = false, - val showOpeningOverlay: Boolean = false, - val openingArtwork: String? = null, - val openingLogo: String? = null, - val openingTitle: String = "", - val openingMessage: String? = null, - val openingProgress: Float? = null, - val skipPromptVisible: Boolean = false, - val skipPromptLabel: String = "Skip", - val skipPromptStartMs: Long = 0L, - val skipPromptEndMs: Long = 0L, - val skipPromptDismissed: Boolean = false, - val nextEpisodeVisible: Boolean = false, - val nextEpisodeHeaderLabel: String = "Next episode", - val nextEpisodeTitle: String = "", - val nextEpisodeThumbnail: String = "", - val nextEpisodeStatus: String = "", - val nextEpisodeActionLabel: String = "Play", - val nextEpisodePlayable: Boolean = false, - val showSubmitIntro: Boolean = false, - val showVideoSettings: Boolean = false, - val showSources: Boolean = false, - val showEpisodes: Boolean = false, - val showExternalPlayer: Boolean = false, - val durationMs: Long = 0L, - val positionMs: Long = 0L, - val sourceIsLoading: Boolean = false, - val sourceFilters: List = emptyList(), - val sourceItems: List = emptyList(), - val episodeItems: List = emptyList(), - val episodeSeasons: List = emptyList(), - val episodeStreamsVisible: Boolean = false, - val episodeStreamsIsLoading: Boolean = false, - val selectedEpisodeLabel: String = "", - val episodeStreamFilters: List = emptyList(), - val episodeStreamItems: List = emptyList(), - val submitIntroSegmentType: String = "intro", - val submitIntroStartTime: String = "00:00", - val submitIntroEndTime: String = "00:00", - val isSubmitIntroSubmitting: Boolean = false, - val submitIntroStatusMessage: String = "", - val showP2pConsent: Boolean = false, - val subtitleActiveTab: String = "BuiltIn", - val addonSubtitleItems: List = emptyList(), - val isLoadingAddonSubtitles: Boolean = false, - val selectedAddonSubtitleId: String = "", - val useCustomSubtitles: Boolean = false, - val subtitleStyle: SubtitleStyleState = SubtitleStyleState.DEFAULT, - val subtitleDelayMs: Int = 0, - val hasSelectedAddonSubtitle: Boolean = false, - val subtitleAutoSyncCapturedPositionMs: Long = -1L, - val subtitleAutoSyncCues: List = emptyList(), - val subtitleAutoSyncIsLoading: Boolean = false, - val subtitleAutoSyncErrorMessage: String = "", - val closeModalsToken: Long = 0L, -) - -data class PlayerControlFilterItem( - val id: String = "", - val label: String = "", - val isSelected: Boolean = false, - val isLoading: Boolean = false, - val hasError: Boolean = false, -) - -data class PlayerControlSeasonItem( - val season: Int = 0, - val label: String = "", - val isSelected: Boolean = false, -) - -data class PlayerControlSourceItem( - val index: Int = 0, - val filterId: String = "", - val label: String = "", - val subtitle: String = "", - val addonName: String = "", - val isCurrent: Boolean = false, - val isEnabled: Boolean = true, -) - -data class PlayerControlEpisodeItem( - val index: Int = 0, - val id: String = "", - val title: String = "", - val code: String = "", - val overview: String = "", - val thumbnail: String = "", - val season: Int = 0, - val episode: Int = 0, - val isCurrent: Boolean = false, - val isWatched: Boolean = false, -) - -data class PlayerControlAddonSubtitleItem( - val index: Int = 0, - val id: String = "", - val display: String = "", - val languageLabel: String = "", - val addonName: String = "", - val isSelected: Boolean = false, -) - -data class PlayerControlSubtitleCueItem( - val index: Int = 0, - val timeMs: Long = 0L, - val timeLabel: String = "", - val text: String = "", -) - internal fun sanitizePlaybackHeaders(headers: Map?): Map { val rawHeaders = headers ?: return emptyMap() if (rawHeaders.isEmpty()) return emptyMap() @@ -289,13 +62,7 @@ expect fun PlatformPlayerSurface( modifier: Modifier = Modifier, playWhenReady: Boolean = true, resizeMode: PlayerResizeMode = PlayerResizeMode.Fit, - initialPositionMs: Long = 0L, useNativeController: Boolean = false, - playerControlsState: PlayerControlsState = PlayerControlsState(), - onPlayerControlsAction: (PlayerControlsAction) -> Boolean = { false }, - onPlayerControlsEvent: (String, Double) -> Boolean = { _, _ -> false }, - onPlayerControlsScrubChange: (Long) -> Boolean = { false }, - onPlayerControlsScrubFinished: (Long) -> Boolean = { false }, onControllerReady: (PlayerEngineController) -> Unit, onSnapshot: (PlayerPlaybackSnapshot) -> Unit, onError: (String?) -> Unit, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt index 288fafda..f6a32fe1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt @@ -54,7 +54,7 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage +import coil3.compose.AsyncImage import com.nuvio.app.core.ui.NuvioTokens import com.nuvio.app.core.ui.nuvio import com.nuvio.app.features.debrid.DebridSettingsRepository diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlays.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlays.kt index eeaa2ef9..00405b6f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlays.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlays.kt @@ -62,7 +62,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage +import coil3.compose.AsyncImage import com.nuvio.app.core.ui.NuvioBackButton import com.nuvio.app.core.ui.nuvioTypeScale import nuvio.composeapp.generated.resources.Res diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt index e1e5f81f..c0436c9d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt @@ -16,7 +16,6 @@ import com.nuvio.app.features.streams.StreamLinkCacheRepository import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.hasLikelyExpiringPlaybackCredentials import com.nuvio.app.features.watchprogress.WatchProgressRepository -import com.nuvio.app.isDesktop import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -231,10 +230,6 @@ internal fun PlayerScreenRuntime.BindPlayerRuntimeEffects() { initialSeekApplied = true return@LaunchedEffect } - if (isDesktop && activeInitialPositionMs > 0L) { - initialSeekApplied = true - return@LaunchedEffect - } controller.seekTo(targetPositionMs) initialSeekApplied = true diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeGestureActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeGestureActions.kt index afabfa86..5d025f5e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeGestureActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeGestureActions.kt @@ -162,33 +162,10 @@ internal fun PlayerScreenRuntime.togglePlayback() { controlsVisible = true } -internal fun PlayerScreenRuntime.prepareTogglePlaybackForNativeFallback(revealControls: Boolean = true) { - shouldPlay = !playbackSnapshot.isPlaying - if (revealControls) { - controlsVisible = true - } -} - internal fun PlayerScreenRuntime.seekBy(offsetMs: Long) { playerController?.seekBy(offsetMs) - applySeekByControlFeedback(offsetMs) -} - -internal fun PlayerScreenRuntime.prepareSeekByForNativeFallback( - offsetMs: Long, - revealControls: Boolean = true, -) { - applySeekByControlFeedback(offsetMs, revealControls) -} - -private fun PlayerScreenRuntime.applySeekByControlFeedback( - offsetMs: Long, - revealControls: Boolean = true, -) { scheduleProgressSyncAfterSeek() - if (revealControls) { - controlsVisible = true - } + controlsVisible = true when { offsetMs > 0L -> showSeekFeedback(PlayerSeekDirection.Forward, offsetMs) offsetMs < 0L -> showSeekFeedback(PlayerSeekDirection.Backward, abs(offsetMs)) @@ -196,17 +173,6 @@ private fun PlayerScreenRuntime.applySeekByControlFeedback( } internal fun PlayerScreenRuntime.handleDoubleTapSeek(direction: PlayerSeekDirection) { - handleDoubleTapSeek(direction, sendToController = true) -} - -internal fun PlayerScreenRuntime.prepareDoubleTapSeekForNativeFallback(direction: PlayerSeekDirection) { - handleDoubleTapSeek(direction, sendToController = false) -} - -private fun PlayerScreenRuntime.handleDoubleTapSeek( - direction: PlayerSeekDirection, - sendToController: Boolean, -) { val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L) val currentSeekState = accumulatedSeekState val nextState = if (currentSeekState?.direction == direction) { @@ -230,9 +196,7 @@ private fun PlayerScreenRuntime.handleDoubleTapSeek( maxDurationMs?.let { unclamped.coerceAtMost(it) } ?: unclamped } } - if (sendToController) { - playerController?.seekTo(targetPositionMs) - } + playerController?.seekTo(targetPositionMs) scheduleProgressSyncAfterSeek() showSeekFeedback(direction, nextState.amountMs) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt index 12b82dc3..0dbc7a27 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt @@ -49,58 +49,6 @@ internal fun PlayerScreenRuntime.p2pSentinelUrl(infoHash: String, fileIdx: Int?) internal fun PlayerScreenRuntime.isP2pStream(stream: StreamItem): Boolean = stream.needsLocalDebridResolve && stream.p2pInfoHash != null -internal fun StreamItem.playerSourceIdentityKey(): String? { - p2pInfoHash?.trim()?.lowercase()?.takeIf { it.isNotBlank() }?.let { hash -> - return "torrent:$hash:${fileIdx ?: -1}" - } - - clientResolve?.let { resolve -> - val raw = resolve.stream?.raw - val keyParts = listOf( - addonId, - resolve.service, - resolve.serviceIndex?.toString(), - resolve.infoHash?.trim()?.lowercase(), - resolve.fileIdx?.toString(), - resolve.magnetUri, - resolve.torrentName, - resolve.filename, - raw?.torrentName, - raw?.filename, - raw?.size?.toString(), - behaviorHints.filename, - behaviorHints.videoSize?.toString(), - streamLabel, - streamSubtitle, - ).map { it.orEmpty().trim() } - if (keyParts.any { it.isNotBlank() }) { - return "resolve:${keyParts.joinToString("|")}" - } - } - - behaviorHints.videoHash?.trim()?.takeIf { it.isNotBlank() }?.let { hash -> - return "hash:$addonId:$hash:${behaviorHints.videoSize ?: ""}:${behaviorHints.filename.orEmpty()}" - } - - playableDirectUrl?.trim()?.takeIf { it.isNotBlank() }?.let { url -> - return "url:$url" - } - - val fallbackParts = listOf( - addonId, - addonName, - streamLabel, - streamSubtitle.orEmpty(), - behaviorHints.filename.orEmpty(), - behaviorHints.videoSize?.toString().orEmpty(), - sourceName.orEmpty(), - sources.joinToString(","), - ).map { it.trim() } - return fallbackParts - .takeIf { parts -> parts.any { it.isNotBlank() } } - ?.joinToString(separator = "|", prefix = "meta:") -} - internal fun PlayerScreenRuntime.stopActiveP2pStream() { if (activeTorrentInfoHash != null || p2pResolvedSourceUrl != null) { P2pStreamingEngine.stopStream() @@ -168,7 +116,6 @@ internal fun PlayerScreenRuntime.switchToP2pSourceStream(stream: StreamItem) { activeTorrentFileIdx = stream.fileIdx activeTorrentFilename = stream.behaviorHints.filename activeTorrentTrackers = stream.p2pTrackers - activeSourceIdentityKey = stream.playerSourceIdentityKey() activeStreamTitle = stream.streamLabel activeStreamSubtitle = stream.streamSubtitle activeProviderName = stream.addonName @@ -239,11 +186,7 @@ internal fun PlayerScreenRuntime.switchToSource(stream: StreamItem) { return } val url = stream.playableDirectUrl ?: return - val sourceIdentityKey = stream.playerSourceIdentityKey() - if (url == activeSourceUrl) { - activeSourceIdentityKey = sourceIdentityKey ?: activeSourceIdentityKey - return - } + if (url == activeSourceUrl) return val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L) flushWatchProgress() stopActiveP2pStream() @@ -255,7 +198,6 @@ internal fun PlayerScreenRuntime.switchToSource(stream: StreamItem) { activeSourceAudioUrl = null activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request) activeSourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response) - activeSourceIdentityKey = sourceIdentityKey activeStreamTitle = stream.streamLabel activeStreamSubtitle = stream.streamSubtitle activeProviderName = stream.addonName @@ -329,7 +271,6 @@ internal fun PlayerScreenRuntime.switchToDownloadedEpisode(downloadItem: Downloa activeSourceAudioUrl = null activeSourceHeaders = emptyMap() activeSourceResponseHeaders = emptyMap() - activeSourceIdentityKey = null activeStreamTitle = downloadItem.streamTitle.ifBlank { episode.title.ifBlank { title } } @@ -435,7 +376,6 @@ private fun PlayerScreenRuntime.applyEpisodeStreamMetadata( episode: MetaVideo, resume: EpisodeResume, ) { - activeSourceIdentityKey = stream.playerSourceIdentityKey() activeStreamTitle = stream.streamLabel activeStreamSubtitle = stream.streamSubtitle activeProviderName = stream.addonName diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt index ed4f44ea..73145102 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt @@ -66,8 +66,8 @@ internal class PlayerScreenRuntime( var metaScreenSettingsUiState: MetaScreenSettingsUiState = MetaScreenSettingsUiState() var watchedUiState: WatchedUiState = WatchedUiState() var watchProgressUiState: WatchProgressUiState = WatchProgressUiState() - var sourceStreamsState by mutableStateOf(StreamsUiState()) - var episodeStreamsRepoState by mutableStateOf(StreamsUiState()) + var sourceStreamsState: StreamsUiState = StreamsUiState() + var episodeStreamsRepoState: StreamsUiState = StreamsUiState() var metaUiState: MetaDetailsUiState = MetaDetailsUiState() var addonsUiState: AddonsUiState = AddonsUiState() var addonSubtitles: List = emptyList() @@ -100,11 +100,6 @@ internal class PlayerScreenRuntime( var activeTorrentFilename by mutableStateOf(torrentFilename) var activeTorrentTrackers by mutableStateOf(torrentTrackers) var p2pResolvedSourceUrl by mutableStateOf(null) - var activeSourceIdentityKey by mutableStateOf( - torrentInfoHash?.trim()?.lowercase()?.takeIf { it.isNotBlank() }?.let { hash -> - "torrent:$hash:${torrentFileIdx ?: -1}" - } ?: sourceUrl.trim().takeIf { it.isNotBlank() }?.let { url -> "url:$url" }, - ) var activeStreamTitle by mutableStateOf(streamTitle) var activeStreamSubtitle by mutableStateOf(streamSubtitle) var activeProviderName by mutableStateOf(providerName) @@ -155,12 +150,6 @@ internal class PlayerScreenRuntime( var submitIntroSegmentType by mutableStateOf("intro") var submitIntroStartTimeStr by mutableStateOf("00:00") var submitIntroEndTimeStr by mutableStateOf("00:00") - var submitIntroStartTimeSec by mutableStateOf(0.0) - var submitIntroEndTimeSec by mutableStateOf(0.0) - var isSubmitIntroSubmitting by mutableStateOf(false) - var submitIntroStatusMessage by mutableStateOf(null) - var playerControlsPendingP2pSwitch by mutableStateOf(null) - var playerControlsCloseModalsToken by mutableStateOf(0L) var episodeStreamsPanelState by mutableStateOf(EpisodeStreamsPanelState()) var playerMetaVideos by mutableStateOf>(emptyList()) var skipIntervals by mutableStateOf>(emptyList()) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt index f28ca333..3119c79f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt @@ -7,42 +7,21 @@ import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.graphics.Color import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onSizeChanged -import com.nuvio.app.core.ui.nuvio -import com.nuvio.app.features.debrid.DebridSettingsRepository -import com.nuvio.app.features.details.MetaDetailsRepository -import com.nuvio.app.features.details.MetaVideo -import com.nuvio.app.features.p2p.P2pSettingsRepository import com.nuvio.app.features.p2p.P2pStreamingState import com.nuvio.app.features.p2p.formatP2pMegabytes import com.nuvio.app.features.p2p.formatP2pSpeed -import com.nuvio.app.features.player.skip.SkipIntroRepository -import com.nuvio.app.features.streams.AddonStreamGroup -import com.nuvio.app.features.streams.StreamItem -import com.nuvio.app.features.streams.isSelectableForPlayback -import com.nuvio.app.features.watchprogress.buildPlaybackVideoId -import com.nuvio.app.features.watching.application.WatchingState -import com.nuvio.app.isDesktop import com.nuvio.app.isIos import kotlinx.coroutines.launch -import kotlin.math.abs -import kotlin.math.roundToInt import nuvio.composeapp.generated.resources.* -import org.jetbrains.compose.resources.stringResource @Composable internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { val runtime = this val displayedPositionMs = scrubbingPositionMs ?: playbackSnapshot.positionMs - val seasonNumber = activeSeasonNumber - val episodeNumber = activeEpisodeNumber - val episodeTitle = activeEpisodeTitle - val isEpisode = seasonNumber != null && episodeNumber != null + val isEpisode = activeSeasonNumber != null && activeEpisodeNumber != null val currentGestureFeedback = liveGestureFeedback ?: gestureFeedback val isP2pPlaybackActive = activeTorrentInfoHash != null val p2pStats = p2pStreamingState as? P2pStreamingState.Streaming @@ -102,229 +81,6 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { (bufferedSeconds / 10f).coerceIn(0f, 1f) } } - val playerSurfaceSourceUrl = if (isP2pPlaybackActive) p2pResolvedSourceUrl else activeSourceUrl - val openingOverlayWanted = playerSettingsUiState.showLoadingOverlay && - !initialLoadCompleted && - errorMessage == null - val episodeText = if (seasonNumber != null && episodeNumber != null && !episodeTitle.isNullOrBlank()) { - stringResource( - Res.string.compose_player_episode_title_format, - seasonNumber, - episodeNumber, - episodeTitle.orEmpty(), - ) - } else { - "" - } - val allFilterLabel = stringResource(Res.string.collections_tab_all) - val playingLabel = stringResource(Res.string.compose_player_playing) - val sourceFilters = buildPlayerControlFilters( - allLabel = allFilterLabel, - selectedFilter = null, - ) - val sourceItems = buildPlayerControlSourceItems() - val episodeItems = buildPlayerControlEpisodeItems() - val episodeSeasons = buildPlayerControlSeasonItems(episodeItems) - val episodeStreamFilters = buildPlayerControlEpisodeStreamFilters( - allLabel = allFilterLabel, - selectedFilter = null, - ) - val episodeStreamItems = buildPlayerControlEpisodeStreamItems() - val playerControlAddonSubtitles = buildPlayerControlAddonSubtitleItems() - val playerControlAutoSyncCues = buildPlayerControlSubtitleCueItems() - val themeColors = MaterialTheme.nuvio.colors - val selectedEpisodeLabel = episodeStreamsPanelState.selectedEpisode?.let { selected -> - val selectedCode = selected.playerControlsEpisodeCode() - buildString { - append(selectedCode) - if (selected.title.isNotBlank()) { - if (isNotEmpty()) append(" • ") - append(selected.title) - } - } - }.orEmpty() - val nativeSkipInterval = activeSkipInterval.takeIf { initialLoadCompleted && !pausedOverlayVisible } - val nextEpisodeForControls = nextEpisodeInfo.takeIf { isSeries && showNextEpisodeCard } - val nextEpisodeStatus = when { - nextEpisodeForControls == null -> "" - !nextEpisodeForControls.hasAired && !nextEpisodeForControls.unairedMessage.isNullOrBlank() -> - nextEpisodeForControls.unairedMessage.orEmpty() - nextEpisodeAutoPlaySearching -> stringResource(Res.string.player_next_episode_finding_source) - !nextEpisodeAutoPlaySourceName.isNullOrBlank() && nextEpisodeAutoPlayCountdown != null -> - stringResource( - Res.string.player_next_episode_playing_via_countdown, - nextEpisodeAutoPlaySourceName.orEmpty(), - nextEpisodeAutoPlayCountdown ?: 0, - ) - else -> "" - } - val playerControlsState = PlayerControlsState( - title = title, - episodeText = episodeText, - streamTitle = activeStreamTitle, - providerName = activeProviderName, - pauseOverlayWatchingLabel = stringResource(Res.string.compose_player_youre_watching), - pauseOverlayLogo = logo, - pauseOverlayEpisodeInfo = if (seasonNumber != null && episodeNumber != null) { - stringResource(Res.string.compose_player_episode_code_full, seasonNumber, episodeNumber) - } else { - activeProviderName - }, - pauseOverlayEpisodeTitle = activeEpisodeTitle.orEmpty(), - pauseOverlayDescription = (pauseDescription ?: activeStreamSubtitle).orEmpty(), - resizeModeLabel = stringResource(resizeMode.labelRes), - playbackSpeedLabel = formatPlaybackSpeedLabel(playbackSnapshot.playbackSpeed), - subtitlesLabel = stringResource(Res.string.compose_player_subs), - audioLabel = stringResource(Res.string.compose_player_audio), - sourcesLabel = stringResource(Res.string.compose_player_sources), - episodesLabel = stringResource(Res.string.compose_player_episodes), - externalPlayerLabel = stringResource(Res.string.streams_open_external_player), - playLabel = stringResource(Res.string.detail_btn_play), - pauseLabel = stringResource(Res.string.compose_action_pause), - closeLabel = stringResource(Res.string.compose_player_close), - lockLabel = stringResource(Res.string.compose_player_lock_controls), - unlockLabel = stringResource(Res.string.compose_player_unlock_controls), - submitIntroLabel = stringResource(Res.string.submit_intro_action), - videoSettingsLabel = stringResource(Res.string.player_action_video_settings), - tapToUnlockLabel = stringResource(Res.string.compose_player_tap_to_unlock), - playbackErrorTitle = stringResource(Res.string.compose_player_playback_error), - playbackErrorMessage = errorMessage.orEmpty(), - playbackErrorActionLabel = stringResource(Res.string.compose_player_go_back), - sourcesPanelTitle = stringResource(Res.string.compose_player_panel_sources), - episodesPanelTitle = stringResource(Res.string.compose_player_panel_episodes), - streamsPanelTitle = stringResource(Res.string.compose_player_panel_streams), - allFilterLabel = allFilterLabel, - reloadLabel = stringResource(Res.string.compose_action_reload), - backLabel = stringResource(Res.string.action_back), - panelCloseLabel = stringResource(Res.string.action_close), - cancelLabel = stringResource(Res.string.action_cancel), - playingLabel = playingLabel, - noStreamsLabel = stringResource(Res.string.compose_player_no_streams_found), - noEpisodesLabel = stringResource(Res.string.compose_player_no_episodes_available), - submitIntroPanelTitle = stringResource(Res.string.submit_intro_title), - submitIntroSegmentTypeLabel = stringResource(Res.string.submit_intro_segment_type_label), - submitIntroSegmentIntroLabel = stringResource(Res.string.submit_intro_segment_intro), - submitIntroSegmentRecapLabel = stringResource(Res.string.submit_intro_segment_recap), - submitIntroSegmentOutroLabel = stringResource(Res.string.submit_intro_segment_outro), - submitIntroStartTimeLabel = stringResource(Res.string.submit_intro_start_time_label), - submitIntroEndTimeLabel = stringResource(Res.string.submit_intro_end_time_label), - submitIntroCaptureLabel = stringResource(Res.string.submit_intro_capture_button), - submitIntroSubmitLabel = stringResource(Res.string.submit_intro_button_submit), - p2pConsentTitle = stringResource(Res.string.p2p_consent_title), - p2pConsentBody = stringResource(Res.string.p2p_consent_body), - p2pConsentEnableLabel = stringResource(Res.string.p2p_consent_enable), - p2pConsentCancelLabel = stringResource(Res.string.p2p_consent_cancel), - subtitlesPanelTitle = stringResource(Res.string.compose_player_subtitles), - subtitleBuiltInTabLabel = stringResource(Res.string.compose_player_built_in), - subtitleAddonsTabLabel = stringResource(Res.string.addon_title), - subtitleStyleTabLabel = stringResource(Res.string.compose_player_style), - noneLabel = stringResource(Res.string.compose_player_none), - fetchSubtitlesLabel = stringResource(Res.string.compose_player_fetch_subtitles), - subtitleDelayLabel = stringResource(Res.string.compose_player_subtitle_delay), - resetLabel = stringResource(Res.string.compose_player_reset), - autoSyncLabel = stringResource(Res.string.compose_player_auto_sync), - reloadSmallLabel = stringResource(Res.string.compose_player_reload), - captureLineLabel = stringResource(Res.string.compose_player_capture_line), - selectAddonSubtitleFirstLabel = stringResource(Res.string.compose_player_select_addon_subtitle_first), - loadingSubtitleLinesLabel = stringResource(Res.string.compose_player_loading_lines), - fontSizeLabel = stringResource(Res.string.compose_player_font_size), - outlineLabel = stringResource(Res.string.compose_player_outline), - boldLabel = stringResource(Res.string.compose_player_bold), - bottomOffsetLabel = stringResource(Res.string.compose_player_bottom_offset), - colorLabel = stringResource(Res.string.compose_player_color), - textOpacityLabel = stringResource(Res.string.compose_player_text_opacity), - outlineColorLabel = stringResource(Res.string.compose_player_outline_color), - resetDefaultsLabel = stringResource(Res.string.compose_player_reset_defaults), - onLabel = stringResource(Res.string.compose_action_on), - offLabel = stringResource(Res.string.compose_action_off), - themeAccentColor = themeColors.accent.toCssColorString(), - themeAccentStrongColor = themeColors.accentStrong.toCssColorString(), - themeOnAccentColor = themeColors.onAccent.toCssColorString(), - themeFocusColor = themeColors.focusRing.toCssColorString(), - themeSelectedSurfaceColor = themeColors.accent.copy(alpha = 0.24f).toCssColorString(), - themeSelectedSurfaceHoverColor = themeColors.accent.copy(alpha = 0.34f).toCssColorString(), - themeSelectedRingColor = themeColors.accent.copy(alpha = 0.35f).toCssColorString(), - themeTimelineFillColor = themeColors.playerTimelineFill.toCssColorString(), - themeTimelineTrackColor = themeColors.playerTimelineTrack.toCssColorString(), - themeBufferingColor = themeColors.playerBuffering.toCssColorString(), - themeBufferingTrackColor = themeColors.playerBuffering.copy(alpha = 0.28f).toCssColorString(), - themeControlForegroundColor = themeColors.playerControlsForeground.toCssColorString(), - isPlaying = playbackSnapshot.isPlaying, - isLoading = playbackSnapshot.isLoading, - isLocked = playerControlsLocked, - lockedOverlayVisible = lockedOverlayVisible, - controlsVisible = controlsVisible && !playerControlsLocked, - parentalWarnings = parentalWarnings, - showParentalGuide = showParentalGuide, - showSubmitIntro = isSeries && - playerSettingsUiState.introSubmitEnabled && - playerSettingsUiState.introDbApiKey.isNotBlank() && - !activeSubmitIntroImdbId().isNullOrBlank(), - showVideoSettings = isIos, - showSources = activeVideoId != null, - showEpisodes = isSeries, - showExternalPlayer = args.onOpenInExternalPlayer != null, - durationMs = playbackSnapshot.durationMs, - positionMs = displayedPositionMs, - sourceIsLoading = sourceStreamsState.isAnyLoading, - sourceFilters = sourceFilters, - sourceItems = sourceItems, - episodeItems = episodeItems, - episodeSeasons = episodeSeasons, - episodeStreamsVisible = episodeStreamsPanelState.showStreams, - episodeStreamsIsLoading = episodeStreamsRepoState.isAnyLoading, - selectedEpisodeLabel = selectedEpisodeLabel, - episodeStreamFilters = episodeStreamFilters, - episodeStreamItems = episodeStreamItems, - submitIntroSegmentType = submitIntroSegmentType, - submitIntroStartTime = submitIntroStartTimeStr, - submitIntroEndTime = submitIntroEndTimeStr, - isSubmitIntroSubmitting = isSubmitIntroSubmitting, - submitIntroStatusMessage = submitIntroStatusMessage.orEmpty(), - showP2pConsent = playerControlsPendingP2pSwitch != null, - subtitleActiveTab = activeSubtitleTab.name, - addonSubtitleItems = playerControlAddonSubtitles, - isLoadingAddonSubtitles = isLoadingAddonSubtitles, - selectedAddonSubtitleId = selectedAddonSubtitleId.orEmpty(), - useCustomSubtitles = useCustomSubtitles, - subtitleStyle = subtitleStyle, - subtitleDelayMs = subtitleDelayMs, - hasSelectedAddonSubtitle = selectedAddonSubtitle != null, - subtitleAutoSyncCapturedPositionMs = subtitleAutoSyncState.capturedPositionMs ?: -1L, - subtitleAutoSyncCues = playerControlAutoSyncCues, - subtitleAutoSyncIsLoading = subtitleAutoSyncState.isLoading, - subtitleAutoSyncErrorMessage = subtitleAutoSyncState.errorMessage.orEmpty(), - closeModalsToken = playerControlsCloseModalsToken, - showOpeningOverlay = openingOverlayWanted, - openingArtwork = background ?: poster, - openingLogo = logo, - openingTitle = title, - openingMessage = p2pInitialLoadingMessage, - openingProgress = p2pInitialLoadingProgress, - skipPromptVisible = nativeSkipInterval != null && !playerControlsLocked, - skipPromptLabel = skipPromptLabel(nativeSkipInterval?.type), - skipPromptStartMs = ((nativeSkipInterval?.startTime ?: 0.0) * 1000).toLong().coerceAtLeast(0L), - skipPromptEndMs = ((nativeSkipInterval?.endTime ?: 0.0) * 1000).toLong().coerceAtLeast(0L), - skipPromptDismissed = skipIntervalDismissed, - nextEpisodeVisible = nextEpisodeForControls != null && !playerControlsLocked, - nextEpisodeHeaderLabel = stringResource(Res.string.player_next_episode), - nextEpisodeTitle = nextEpisodeForControls?.let { - stringResource( - Res.string.compose_player_episode_title_format, - it.season, - it.episode, - it.title, - ) - }.orEmpty(), - nextEpisodeThumbnail = nextEpisodeForControls?.thumbnail.orEmpty(), - nextEpisodeStatus = nextEpisodeStatus, - nextEpisodeActionLabel = if (nextEpisodeForControls?.hasAired == true) { - stringResource(Res.string.detail_btn_play) - } else { - stringResource(Res.string.player_next_episode_unaired) - }, - nextEpisodePlayable = nextEpisodeForControls?.hasAired == true, - ) val gestureCallbacks = rememberSurfaceGestureCallbacks() Box( @@ -357,6 +113,7 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { commitHorizontalSeekState = gestureCallbacks.commitHorizontalSeek, ), ) { + val playerSurfaceSourceUrl = if (isP2pPlaybackActive) p2pResolvedSourceUrl else activeSourceUrl if (playerSurfaceSourceUrl != null) { PlatformPlayerSurface( sourceUrl = playerSurfaceSourceUrl, @@ -366,18 +123,6 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { modifier = Modifier.fillMaxSize(), playWhenReady = shouldPlay, resizeMode = resizeMode, - initialPositionMs = activeInitialPositionMs.takeIf { isDesktop } ?: 0L, - playerControlsState = playerControlsState, - onPlayerControlsAction = { action -> handlePlayerControlsAction(action) }, - onPlayerControlsEvent = { type, value -> handlePlayerControlsEvent(type, value) }, - onPlayerControlsScrubChange = { positionMs -> - handlePlayerControlsScrubChange(positionMs) - true - }, - onPlayerControlsScrubFinished = { positionMs -> - handlePlayerControlsScrubFinished(positionMs) - true - }, onControllerReady = { controller -> playerController = controller playerControllerSourceUrl = activeSourceUrl @@ -433,7 +178,6 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { showP2pRebufferStats = showP2pRebufferStats, p2pRebufferMessage = p2pRebufferMessage, p2pRebufferProgress = p2pRebufferProgress, - suppressOpeningOverlay = isDesktop && playerSurfaceSourceUrl != null, ) RenderPlayerModals(displayedPositionMs = displayedPositionMs) } @@ -546,606 +290,6 @@ private fun PlayerScreenRuntime.RenderPlayerControls(displayedPositionMs: Long, } } -private fun PlayerScreenRuntime.handlePlayerControlsAction(action: PlayerControlsAction): Boolean { - when (action) { - PlayerControlsAction.ToggleChrome -> { - if (playerControlsLocked) { - revealLockedOverlay() - } else { - controlsVisible = !controlsVisible - } - } - PlayerControlsAction.RevealLockedOverlay -> revealLockedOverlay() - PlayerControlsAction.Back -> { - flushWatchProgress() - args.onBack() - } - PlayerControlsAction.TogglePlayback -> { - prepareTogglePlaybackForNativeFallback() - return false - } - PlayerControlsAction.KeyboardTogglePlayback -> { - prepareTogglePlaybackForNativeFallback(revealControls = false) - return false - } - PlayerControlsAction.SeekBack -> { - prepareSeekByForNativeFallback(-10_000L) - return false - } - PlayerControlsAction.KeyboardSeekBack -> { - prepareSeekByForNativeFallback(-10_000L, revealControls = false) - return false - } - PlayerControlsAction.SeekForward -> { - prepareSeekByForNativeFallback(10_000L) - return false - } - PlayerControlsAction.KeyboardSeekForward -> { - prepareSeekByForNativeFallback(10_000L, revealControls = false) - return false - } - PlayerControlsAction.ResizeMode -> cycleResizeMode() - PlayerControlsAction.Speed -> cyclePlaybackSpeed() - PlayerControlsAction.Subtitles -> { - refreshTracks() - showSubtitleModal = true - } - PlayerControlsAction.Audio -> { - refreshTracks() - showAudioModal = true - } - PlayerControlsAction.Sources -> { - prepareSourcesForPlayerControls() - } - PlayerControlsAction.Episodes -> { - prepareEpisodesForPlayerControls() - } - PlayerControlsAction.OpenExternalPlayer -> openInExternalPlayer() - PlayerControlsAction.SubmitIntro -> { - submitIntroStatusMessage = null - } - PlayerControlsAction.LockToggle -> { - if (playerControlsLocked) unlockPlayerControls() else lockPlayerControls() - } - PlayerControlsAction.VideoSettings -> { - if (isIos) { - showVideoSettingsModal = true - controlsVisible = true - } - } - PlayerControlsAction.DoubleTapSeekBack -> { - prepareDoubleTapSeekForNativeFallback(PlayerSeekDirection.Backward) - return false - } - PlayerControlsAction.DoubleTapSeekForward -> { - prepareDoubleTapSeekForNativeFallback(PlayerSeekDirection.Forward) - return false - } - } - return true -} - -private fun PlayerScreenRuntime.handlePlayerControlsEvent(type: String, value: Double): Boolean { - when (type) { - "hideChrome" -> { - controlsVisible = false - } - "reloadSources" -> { - prepareSourcesForPlayerControls(forceRefresh = true) - } - "selectSource" -> { - val streams = sourceStreamsState.groups.flatMap { it.streams } - val stream = streams.getOrNull(value.toInt()) ?: return true - if (requestP2pConsentForPlayerControls(stream = stream, episode = null)) return true - switchToSource(stream) - playerControlsCloseModalsToken += 1 - } - "selectEpisode" -> { - val episode = playerMetaVideos.getOrNull(value.toInt()) ?: return true - if (selectDownloadedEpisodeForPlayback( - parentMetaId = parentMetaId, - episode = episode, - onDownloadedEpisodeSelected = { item, video -> switchToDownloadedEpisode(item, video) }, - ) - ) { - playerControlsCloseModalsToken += 1 - } else { - requestEpisodeStreamsForPlayerControls(episode) - } - } - "selectEpisodeStream" -> { - val episode = episodeStreamsPanelState.selectedEpisode ?: return true - val stream = episodeStreamsRepoState.groups.flatMap { it.streams }.getOrNull(value.toInt()) ?: return true - if (requestP2pConsentForPlayerControls(stream = stream, episode = episode)) return true - switchToEpisodeStream(stream, episode) - playerControlsCloseModalsToken += 1 - } - "backToEpisodes" -> { - episodeStreamsPanelState = EpisodeStreamsPanelState() - PlayerStreamsRepository.clearEpisodeStreams() - } - "reloadEpisodeStreams" -> { - episodeStreamsPanelState.selectedEpisode?.let { requestEpisodeStreamsForPlayerControls(it, forceRefresh = true) } - } - "submitIntroSegment" -> { - submitIntroSegmentType = when (value.toInt()) { - 1 -> "recap" - 2 -> "outro" - else -> "intro" - } - submitIntroStatusMessage = null - } - "submitIntroStart" -> { - val seconds = value.takeIf { it.isFinite() && it >= 0.0 } ?: 0.0 - submitIntroStartTimeSec = seconds - submitIntroStartTimeStr = formatPlayerControlsSeconds(seconds) - submitIntroStatusMessage = null - } - "submitIntroEnd" -> { - val seconds = value.takeIf { it.isFinite() && it >= 0.0 } ?: 0.0 - submitIntroEndTimeSec = seconds - submitIntroEndTimeStr = formatPlayerControlsSeconds(seconds) - submitIntroStatusMessage = null - } - "submitIntroCommit" -> submitIntroFromPlayerControls() - "skipInterval" -> { - val interval = activeSkipInterval ?: return true - playerController?.seekTo((interval.endTime * 1000).toLong()) - scheduleProgressSyncAfterSeek() - skipIntervalDismissed = true - } - "playNextEpisode" -> { - if (nextEpisodeInfo?.hasAired == true) { - nextEpisodeAutoPlayJob?.cancel() - playNextEpisode() - } - } - "enableP2pForPlayerControls" -> enableP2pForPlayerControls() - "cancelP2pForPlayerControls" -> { - playerControlsPendingP2pSwitch = null - } - "subtitleTab" -> { - activeSubtitleTab = when (value.toInt()) { - 1 -> SubtitleTab.Addons - 2 -> SubtitleTab.Style - else -> SubtitleTab.BuiltIn - } - } - "selectBuiltInSubtitleTrack" -> { - val index = value.toInt() - val wasCustom = useCustomSubtitles - selectedSubtitleIndex = index - selectedAddonSubtitleId = null - useCustomSubtitles = false - persistInternalSubtitlePreference(subtitleTracks.firstOrNull { it.index == index }) - if (wasCustom) { - playerController?.clearExternalSubtitleAndSelect(index) - } else { - playerController?.selectSubtitleTrack(index) - } - } - "fetchAddonSubtitles" -> fetchAddonSubtitlesForActiveItem() - "selectAddonSubtitle" -> { - val addon = visibleAddonSubtitles.getOrNull(value.toInt()) ?: return true - selectedAddonSubtitleId = addon.id - selectedSubtitleIndex = -1 - useCustomSubtitles = true - persistAddonSubtitlePreference(addon) - playerController?.setSubtitleUri(addon.url) - } - "subtitleDelayDelta" -> setSubtitleDelay((subtitleDelayMs + value.toInt()).coerceIn(SUBTITLE_DELAY_MIN_MS, SUBTITLE_DELAY_MAX_MS)) - "subtitleDelayReset" -> setSubtitleDelay(0) - "subtitleAutoSyncCapture" -> captureSubtitleAutoSyncTime() - "subtitleAutoSyncReload" -> loadSubtitleAutoSyncCues(force = true) - "subtitleAutoSyncCue" -> { - val cue = playerControlsNearestSubtitleCues().getOrNull(value.toInt()) ?: return true - applySubtitleAutoSyncCue(cue) - } - "subtitleFontSizeDelta" -> { - PlayerSettingsRepository.setSubtitleStyle( - subtitleStyle.copy(fontSizeSp = (subtitleStyle.fontSizeSp + value.toInt()).coerceIn(12, 40)), - ) - } - "subtitleOutlineToggle" -> { - PlayerSettingsRepository.setSubtitleStyle(subtitleStyle.copy(outlineEnabled = !subtitleStyle.outlineEnabled)) - } - "subtitleBoldToggle" -> { - PlayerSettingsRepository.setSubtitleStyle(subtitleStyle.copy(bold = !subtitleStyle.bold)) - } - "subtitleBottomOffsetDelta" -> { - PlayerSettingsRepository.setSubtitleStyle( - subtitleStyle.copy(bottomOffset = (subtitleStyle.bottomOffset + value.toInt()).coerceIn(0, 200)), - ) - } - "subtitleTextColor" -> { - SubtitleColorSwatches.getOrNull(value.toInt())?.let { color -> - PlayerSettingsRepository.setSubtitleStyle(subtitleStyle.copy(textColor = color.copy(alpha = subtitleStyle.textColor.alpha))) - } - } - "subtitleOutlineColor" -> { - SubtitleColorSwatches.getOrNull(value.toInt())?.let { color -> - PlayerSettingsRepository.setSubtitleStyle(subtitleStyle.copy(outlineColor = color.copy(alpha = subtitleStyle.outlineColor.alpha))) - } - } - "subtitleTextOpacity" -> { - val alpha = (value.toFloat() / 100f).coerceIn(0f, 1f) - PlayerSettingsRepository.setSubtitleStyle(subtitleStyle.copy(textColor = subtitleStyle.textColor.copy(alpha = alpha))) - } - "subtitleStyleReset" -> PlayerSettingsRepository.setSubtitleStyle(SubtitleStyleState.DEFAULT) - "parentalGuideComplete" -> { - showParentalGuide = false - } - else -> return false - } - return true -} - -private fun PlayerScreenRuntime.requestP2pConsentForPlayerControls( - stream: StreamItem, - episode: MetaVideo?, -): Boolean { - if (!isP2pStream(stream)) return false - if (!P2pSettingsRepository.isVisible) return false - if (P2pSettingsRepository.uiState.value.p2pEnabled) return false - playerControlsPendingP2pSwitch = PendingPlayerP2pSwitch( - stream = stream, - episode = episode, - isAutoPlay = false, - ) - return true -} - -private fun PlayerScreenRuntime.enableP2pForPlayerControls() { - val pending = playerControlsPendingP2pSwitch ?: return - playerControlsPendingP2pSwitch = null - P2pSettingsRepository.setP2pEnabled(true) - val episode = pending.episode - if (episode != null) { - switchToP2pEpisodeStream(pending.stream, episode, pending.isAutoPlay) - } else { - switchToP2pSourceStream(pending.stream) - } - playerControlsCloseModalsToken += 1 -} - -private fun PlayerScreenRuntime.prepareSourcesForPlayerControls(forceRefresh: Boolean = false) { - val vid = activeVideoId - if (vid == null) { - return - } - val requestType = contentType ?: parentMetaType - PlayerStreamsRepository.loadSources( - type = requestType, - videoId = vid, - season = activeSeasonNumber, - episode = activeEpisodeNumber, - forceRefresh = forceRefresh, - ) -} - -private fun Color.toCssColorString(): String { - val redInt = (red * 255f).roundToInt().coerceIn(0, 255) - val greenInt = (green * 255f).roundToInt().coerceIn(0, 255) - val blueInt = (blue * 255f).roundToInt().coerceIn(0, 255) - val alphaValue = alpha.coerceIn(0f, 1f) - return "rgba($redInt, $greenInt, $blueInt, ${alphaValue.toCssAlphaString()})" -} - -private fun Float.toCssAlphaString(): String { - val rounded = (this * 1000f).roundToInt() / 1000f - return rounded.toString().trimEnd('0').trimEnd('.').ifEmpty { "0" } -} - -private fun PlayerScreenRuntime.prepareEpisodesForPlayerControls() { - if (!isSeries) return - if (playerMetaVideos.isEmpty()) { - scope.launch { - playerMetaVideos = MetaDetailsRepository.fetch(parentMetaType, parentMetaId)?.videos ?: emptyList() - } - } -} - -private fun PlayerScreenRuntime.requestEpisodeStreamsForPlayerControls( - episode: MetaVideo, - forceRefresh: Boolean = false, -) { - PlayerStreamsRepository.loadEpisodeStreams( - type = contentType ?: parentMetaType, - videoId = episode.id, - season = episode.season, - episode = episode.episode, - forceRefresh = forceRefresh, - ) - episodeStreamsPanelState = EpisodeStreamsPanelState(showStreams = true, selectedEpisode = episode) -} - -private fun PlayerScreenRuntime.submitIntroFromPlayerControls() { - if (isSubmitIntroSubmitting) return - val imdbId = activeSubmitIntroImdbId() - val season = activeSeasonNumber - val episode = activeEpisodeNumber - val start = submitIntroStartTimeSec - val end = submitIntroEndTimeSec - if (imdbId.isNullOrBlank() || season == null || episode == null || start == null || end == null || end <= start) { - submitIntroStatusMessage = "Check the start and end times." - return - } - isSubmitIntroSubmitting = true - submitIntroStatusMessage = null - scope.launch { - val result = SkipIntroRepository.submitIntro( - imdbId = imdbId, - season = season, - episode = episode, - startSec = start, - endSec = end, - segmentType = submitIntroSegmentType, - ) - isSubmitIntroSubmitting = false - if (result) { - submitIntroStartTimeSec = 0.0 - submitIntroEndTimeSec = 0.0 - submitIntroStartTimeStr = "00:00" - submitIntroEndTimeStr = "00:00" - submitIntroSegmentType = "intro" - submitIntroStatusMessage = null - playerControlsCloseModalsToken += 1 - } else { - submitIntroStatusMessage = "Unable to submit timestamps." - } - } -} - -private fun PlayerScreenRuntime.activeSubmitIntroImdbId(): String? = - activeVideoId?.split(":")?.firstOrNull()?.takeIf { it.startsWith("tt") } - ?: parentMetaId.takeIf { it.startsWith("tt") } - ?: metaUiState.meta?.id?.takeIf { it.startsWith("tt") } - -@Composable -private fun skipPromptLabel(type: String?): String = - when (type?.lowercase()) { - "intro", "op", "mixed-op" -> stringResource(Res.string.player_skip_intro) - "outro", "ed", "mixed-ed", "credits" -> stringResource(Res.string.player_skip_outro) - "recap" -> stringResource(Res.string.player_skip_recap) - else -> stringResource(Res.string.player_skip) - } - -private fun formatPlayerControlsSeconds(seconds: Double): String { - val totalSeconds = seconds - .takeIf { it.isFinite() && it >= 0.0 } - ?.toLong() - ?: 0L - val minutes = totalSeconds / 60L - val remainder = totalSeconds % 60L - return "${minutes.toString().padStart(2, '0')}:${remainder.toString().padStart(2, '0')}" -} - -private fun PlayerScreenRuntime.handlePlayerControlsScrubChange(positionMs: Long) { - isScrubbingTimeline = true - scrubbingPositionMs = positionMs -} - -private fun PlayerScreenRuntime.handlePlayerControlsScrubFinished(positionMs: Long) { - isScrubbingTimeline = false - scrubbingPositionMs = null - playerController?.seekTo(positionMs) - scheduleProgressSyncAfterSeek() -} - -private fun PlayerScreenRuntime.openInExternalPlayer() { - val openExternal = args.onOpenInExternalPlayer ?: return - val loadedSubtitles = addonSubtitles - .takeIf { it.isNotEmpty() } - ?.map { sub -> - SubtitleInput( - url = sub.url, - name = buildString { - if (!sub.addonName.isNullOrBlank()) append("[${sub.addonName}] ") - append(sub.display) - }, - lang = sub.language, - ) - } - openExternal( - ExternalPlayerPlaybackRequest( - sourceUrl = activeSourceUrl, - title = title, - streamTitle = activeStreamTitle, - sourceHeaders = activeSourceHeaders, - resumePositionMs = playbackSnapshot.positionMs, - subtitles = loadedSubtitles, - ), - ) -} - -private fun PlayerScreenRuntime.buildPlayerControlFilters( - groups: List = sourceStreamsState.groups, - allLabel: String, - selectedFilter: String?, -): List { - if (groups.size <= 1) return emptyList() - return buildList { - add(PlayerControlFilterItem(id = "", label = allLabel, isSelected = selectedFilter == null)) - groups.distinctBy { it.addonId }.forEach { group -> - add( - PlayerControlFilterItem( - id = group.addonId, - label = group.addonName, - isSelected = selectedFilter == group.addonId, - isLoading = group.isLoading, - hasError = group.error != null, - ), - ) - } - } -} - -private fun PlayerScreenRuntime.buildPlayerControlEpisodeStreamFilters( - allLabel: String, - selectedFilter: String?, -): List = - buildPlayerControlFilters( - groups = episodeStreamsRepoState.groups, - allLabel = allLabel, - selectedFilter = selectedFilter, - ) - -private fun PlayerScreenRuntime.buildPlayerControlSourceItems(): List { - val canResolveDebrid = DebridSettingsRepository.uiState.value.canResolvePlayableLinks - return sourceStreamsState.groups.flatMap { group -> - group.streams.map { stream -> group.addonId to stream } - }.mapIndexed { index, (filterId, stream) -> - PlayerControlSourceItem( - index = index, - filterId = filterId, - label = stream.streamLabel, - subtitle = stream.streamSubtitle.orEmpty(), - addonName = stream.addonName, - isCurrent = isCurrentPlayerControlStream(stream), - isEnabled = stream.isSelectableForPlayback(canResolveDebrid), - ) - } -} - -private fun PlayerScreenRuntime.buildPlayerControlEpisodeStreamItems(): List { - val canResolveDebrid = DebridSettingsRepository.uiState.value.canResolvePlayableLinks - return episodeStreamsRepoState.groups.flatMap { group -> - group.streams.map { stream -> group.addonId to stream } - }.mapIndexed { index, (filterId, stream) -> - PlayerControlSourceItem( - index = index, - filterId = filterId, - label = stream.streamLabel, - subtitle = stream.streamSubtitle.orEmpty(), - addonName = stream.addonName, - isCurrent = false, - isEnabled = stream.isSelectableForPlayback(canResolveDebrid), - ) - } -} - -private fun PlayerScreenRuntime.isCurrentPlayerControlStream(stream: StreamItem): Boolean { - val activeKey = activeSourceIdentityKey - val streamKey = stream.playerSourceIdentityKey() - if (activeKey != null) { - return streamKey == activeKey - } - val directUrl = stream.playableDirectUrl - if (directUrl != null && directUrl == activeSourceUrl) return true - val infoHash = stream.p2pInfoHash - if (infoHash != null && infoHash == activeTorrentInfoHash) return true - return false -} - -@Composable -private fun PlayerScreenRuntime.buildPlayerControlAddonSubtitleItems(): List = - visibleAddonSubtitles.mapIndexed { index, subtitle -> - PlayerControlAddonSubtitleItem( - index = index, - id = subtitle.id, - display = subtitle.display, - languageLabel = languageLabelForCode(subtitle.language), - addonName = subtitle.addonName.orEmpty(), - isSelected = subtitle.id == selectedAddonSubtitleId || subtitle.url == selectedAddonSubtitleId, - ) - } - -private fun PlayerScreenRuntime.buildPlayerControlSubtitleCueItems(): List = - playerControlsNearestSubtitleCues().mapIndexed { index, cue -> - PlayerControlSubtitleCueItem( - index = index, - timeMs = cue.startTimeMs, - timeLabel = formatPlayerControlsCueTimestamp(cue.startTimeMs), - text = cue.text, - ) - } - -private fun PlayerScreenRuntime.playerControlsNearestSubtitleCues(): List { - val capturedPositionMs = subtitleAutoSyncState.capturedPositionMs ?: return emptyList() - return subtitleAutoSyncState.cues - .sortedBy { abs(it.startTimeMs - capturedPositionMs) } - .take(5) -} - -private fun formatPlayerControlsCueTimestamp(timeMs: Long): String { - val totalSeconds = (timeMs / 1000L).coerceAtLeast(0L) - val minutes = totalSeconds / 60L - val seconds = totalSeconds % 60L - return "${minutes}:${seconds.toString().padStart(2, '0')}" -} - -@Composable -private fun PlayerScreenRuntime.buildPlayerControlEpisodeItems(): List { - val items = mutableListOf() - for ((index, video) in playerMetaVideos.withIndex()) { - if (video.season == null && video.episode == null) continue - val episodeVideoId = buildPlaybackVideoId( - parentMetaId = parentMetaId, - seasonNumber = video.season, - episodeNumber = video.episode, - fallbackVideoId = video.id, - ) - val isWatched = watchProgressUiState.byVideoId[episodeVideoId]?.isEffectivelyCompleted == true || - WatchingState.isEpisodeWatched( - watchedKeys = watchedUiState.watchedKeys, - metaType = parentMetaType, - metaId = parentMetaId, - episode = video, - ) - items.add( - PlayerControlEpisodeItem( - index = index, - id = video.id, - title = video.title, - code = video.playerControlsEpisodeCode(), - overview = video.overview.orEmpty(), - thumbnail = video.thumbnail.orEmpty(), - season = video.season?.coerceAtLeast(0) ?: 0, - episode = video.episode ?: 0, - isCurrent = video.season == activeSeasonNumber && video.episode == activeEpisodeNumber, - isWatched = isWatched, - ), - ) - } - return items -} - -@Composable -private fun PlayerScreenRuntime.buildPlayerControlSeasonItems( - episodes: List, -): List { - val availableSeasons = episodes - .map { it.season } - .distinct() - .let { seasons -> - seasons.filter { it > 0 }.sorted() + seasons.filter { it == 0 } - } - val items = mutableListOf() - for (season in availableSeasons) { - val label = if (season == 0) { - stringResource(Res.string.episodes_specials) - } else { - stringResource(Res.string.episodes_season, season) - } - items.add( - PlayerControlSeasonItem( - season = season, - label = label, - isSelected = activeSeasonNumber == season, - ), - ) - } - return items -} - -@Composable -private fun MetaVideo.playerControlsEpisodeCode(): String = - when { - season != null && episode != null -> stringResource(Res.string.compose_player_episode_code_full, season, episode) - episode != null -> stringResource(Res.string.compose_player_episode_code_episode_only, episode) - else -> "" - } - @Composable private fun BoxScope.RenderPlaybackOverlays( runtime: PlayerScreenRuntime, @@ -1156,66 +300,62 @@ private fun BoxScope.RenderPlaybackOverlays( showP2pRebufferStats: Boolean, p2pRebufferMessage: String?, p2pRebufferProgress: Float?, - suppressOpeningOverlay: Boolean, ) { runtime.run { PlayerPlaybackOverlays( playerControlsLocked = playerControlsLocked, lockedOverlayVisible = lockedOverlayVisible, playbackSnapshot = playbackSnapshot, - displayedPositionMs = displayedPositionMs, - metrics = metrics, - horizontalSafePadding = horizontalSafePadding, - onUnlock = { unlockPlayerControls() }, - showOpeningOverlay = playerSettingsUiState.showLoadingOverlay && - !initialLoadCompleted && - errorMessage == null && - !suppressOpeningOverlay, - backdropArtwork = background ?: poster, - logo = logo, - title = title, - onBackWithProgress = { - flushWatchProgress() - args.onBack() - }, - p2pInitialLoadingMessage = p2pInitialLoadingMessage, - p2pInitialLoadingProgress = p2pInitialLoadingProgress, - showP2pRebufferStats = showP2pRebufferStats, - p2pRebufferMessage = p2pRebufferMessage, - p2pRebufferProgress = p2pRebufferProgress, - currentGestureFeedback = currentGestureFeedback, - renderedGestureFeedback = renderedGestureFeedback, - initialLoadCompleted = initialLoadCompleted, - pausedOverlayVisible = pausedOverlayVisible, - activeSkipInterval = activeSkipInterval.takeUnless { isDesktop }, - skipIntervalDismissed = skipIntervalDismissed, - controlsVisible = controlsVisible, - onSkipInterval = { interval -> - playerController?.seekTo((interval.endTime * 1000).toLong()) - scheduleProgressSyncAfterSeek() - skipIntervalDismissed = true - }, - onDismissSkipInterval = { skipIntervalDismissed = true }, - sliderEdgePadding = sliderEdgePadding, - overlayBottomPadding = overlayBottomPadding, - isSeries = isSeries, - nextEpisodeInfo = nextEpisodeInfo, - showNextEpisodeCard = showNextEpisodeCard && !isDesktop, - nextEpisodeAutoPlaySearching = nextEpisodeAutoPlaySearching, - nextEpisodeAutoPlaySourceName = nextEpisodeAutoPlaySourceName, - nextEpisodeAutoPlayCountdown = nextEpisodeAutoPlayCountdown, - onPlayNextEpisode = { - nextEpisodeAutoPlayJob?.cancel() - playNextEpisode() - }, - onDismissNextEpisode = { - nextEpisodeAutoPlayJob?.cancel() - showNextEpisodeCard = false - nextEpisodeAutoPlaySearching = false - nextEpisodeAutoPlaySourceName = null - nextEpisodeAutoPlayCountdown = null - }, - errorMessage = errorMessage, + displayedPositionMs = displayedPositionMs, + metrics = metrics, + horizontalSafePadding = horizontalSafePadding, + onUnlock = { unlockPlayerControls() }, + showOpeningOverlay = playerSettingsUiState.showLoadingOverlay && !initialLoadCompleted && errorMessage == null, + backdropArtwork = background ?: poster, + logo = logo, + title = title, + onBackWithProgress = { + flushWatchProgress() + args.onBack() + }, + p2pInitialLoadingMessage = p2pInitialLoadingMessage, + p2pInitialLoadingProgress = p2pInitialLoadingProgress, + showP2pRebufferStats = showP2pRebufferStats, + p2pRebufferMessage = p2pRebufferMessage, + p2pRebufferProgress = p2pRebufferProgress, + currentGestureFeedback = currentGestureFeedback, + renderedGestureFeedback = renderedGestureFeedback, + initialLoadCompleted = initialLoadCompleted, + pausedOverlayVisible = pausedOverlayVisible, + activeSkipInterval = activeSkipInterval, + skipIntervalDismissed = skipIntervalDismissed, + controlsVisible = controlsVisible, + onSkipInterval = { interval -> + playerController?.seekTo((interval.endTime * 1000).toLong()) + scheduleProgressSyncAfterSeek() + skipIntervalDismissed = true + }, + onDismissSkipInterval = { skipIntervalDismissed = true }, + sliderEdgePadding = sliderEdgePadding, + overlayBottomPadding = overlayBottomPadding, + isSeries = isSeries, + nextEpisodeInfo = nextEpisodeInfo, + showNextEpisodeCard = showNextEpisodeCard, + nextEpisodeAutoPlaySearching = nextEpisodeAutoPlaySearching, + nextEpisodeAutoPlaySourceName = nextEpisodeAutoPlaySourceName, + nextEpisodeAutoPlayCountdown = nextEpisodeAutoPlayCountdown, + onPlayNextEpisode = { + nextEpisodeAutoPlayJob?.cancel() + playNextEpisode() + }, + onDismissNextEpisode = { + nextEpisodeAutoPlayJob?.cancel() + showNextEpisodeCard = false + nextEpisodeAutoPlaySearching = false + nextEpisodeAutoPlaySourceName = null + nextEpisodeAutoPlayCountdown = null + }, + errorMessage = errorMessage, onDismissError = { flushWatchProgress() args.onBack() @@ -1380,9 +520,6 @@ private fun PlayerScreenRuntime.RenderPlayerModals(displayedPositionMs: Long) { onSubmitIntroEndTimeChanged = { submitIntroEndTimeStr = it }, onSubmitIntroDismissed = { showSubmitIntroModal = false }, onSubmitIntroSuccess = { - submitIntroStartTimeSec = 0.0 - submitIntroEndTimeSec = 0.0 - submitIntroStatusMessage = null submitIntroStartTimeStr = "00:00" submitIntroEndTimeStr = "00:00" submitIntroSegmentType = "intro" diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt index c78e27da..1a733fcd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt @@ -63,7 +63,6 @@ object PlayerStreamsRepository { forceRefresh: Boolean = false, ) { fetchStreams( - panelName = "sources", type = type, videoId = videoId, season = season, @@ -85,7 +84,6 @@ object PlayerStreamsRepository { forceRefresh: Boolean = false, ) { fetchStreams( - panelName = "episodeStreams", type = type, videoId = videoId, season = season, @@ -121,7 +119,6 @@ object PlayerStreamsRepository { } private fun fetchStreams( - panelName: String, type: String, videoId: String, season: Int?, @@ -140,11 +137,9 @@ object PlayerStreamsRepository { requestKeyHolder() == requestKey && (current.groups.isNotEmpty() || current.emptyStateReason != null || current.isAnyLoading) ) { - log.d { "skip $panelName request=$requestKey reason=already-active ${current.streamDiagnostics()}" } return } - log.d { "start $panelName request=$requestKey force=$forceRefresh previous=${current.streamDiagnostics()}" } setRequestKey(requestKey) jobHolder()?.cancel() stateFlow.value = StreamsUiState() @@ -152,7 +147,7 @@ object PlayerStreamsRepository { val streamBadgeRules = StreamBadgeSettingsRepository.snapshot() val embeddedStreams = MetaDetailsRepository.findEmbeddedStreams(videoId) if (embeddedStreams.isNotEmpty()) { - log.d { "using embedded $panelName request=$requestKey streams=${embeddedStreams.size}" } + log.d { "Using ${embeddedStreams.size} embedded streams for type=$type id=$videoId" } val group = AddonStreamGroup( addonName = embeddedStreams.first().addonName, addonId = "embedded", @@ -168,7 +163,6 @@ object PlayerStreamsRepository { activeAddonIds = setOf("embedded"), isAnyLoading = false, ) - log.d { "finish $panelName request=$requestKey reason=embedded ${stateFlow.value.streamDiagnostics()}" } return } @@ -189,7 +183,6 @@ object PlayerStreamsRepository { isAnyLoading = false, emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoAddonsInstalled, ) - log.d { "finish $panelName request=$requestKey reason=no-addons ${stateFlow.value.streamDiagnostics()}" } return } @@ -216,10 +209,6 @@ object PlayerStreamsRepository { isAnyLoading = false, emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoCompatibleAddons, ) - log.d { - "finish $panelName request=$requestKey reason=no-compatible-addons " + - "installed=${installedAddons.size} ${stateFlow.value.streamDiagnostics()}" - } return } @@ -233,10 +222,6 @@ object PlayerStreamsRepository { .associateBy { it.addonId } } val warmedAddonIds = warmedAddonGroups.keys - log.d { - "targets $panelName request=$requestKey installed=${installedAddons.size} " + - "compatible=${streamAddons.size} plugins=${pluginScrapers.size} warmed=${warmedAddonIds.size}" - } val initialGroups = StreamAutoPlaySelector.orderAddonStreams(streamAddons.map { addon -> warmedAddonGroups[addon.addonId] ?: AddonStreamGroup( addonName = addon.addonName, @@ -258,7 +243,6 @@ object PlayerStreamsRepository { activeAddonIds = initialGroups.map { it.addonId }.toSet(), isAnyLoading = isInitiallyLoading, ) - log.d { "state $panelName request=$requestKey stage=initial ${stateFlow.value.streamDiagnostics()}" } val job = scope.launch { val pendingStreamAddons = streamAddons.filterNot { it.addonId in warmedAddonIds } @@ -287,7 +271,6 @@ object PlayerStreamsRepository { } fun publishStreamGroup(group: AddonStreamGroup) { - var nextState: StreamsUiState? = null stateFlow.update { current -> val updated = StreamAutoPlaySelector.orderAddonStreams( groups = current.groups.map { currentGroup -> @@ -300,14 +283,7 @@ object PlayerStreamsRepository { groups = updated, isAnyLoading = anyLoading, emptyStateReason = emptyStateReason(updated, anyLoading), - ).also { nextState = it } - } - nextState?.let { state -> - log.d { - "state $panelName request=$requestKey stage=publish addon=${group.addonName} " + - "streams=${group.streams.size} loading=${group.isLoading} " + - "error=${!group.error.isNullOrBlank()} ${state.streamDiagnostics()}" - } + ) } } @@ -353,16 +329,14 @@ object PlayerStreamsRepository { val displayName = addon.addonName runCatching { - log.d { "fetch $panelName request=$requestKey addon=$displayName" } val payload = httpGetText(url) StreamParser.parse(payload, displayName, addon.addonId) }.fold( onSuccess = { streams -> - log.d { "fetched $panelName request=$requestKey addon=$displayName streams=${streams.size}" } AddonStreamGroup(displayName, addon.addonId, streams, isLoading = false) }, onFailure = { err -> - log.w(err) { "failed $panelName request=$requestKey addon=$displayName" } + log.w(err) { "Failed: ${displayName}" } AddonStreamGroup(displayName, addon.addonId, emptyList(), isLoading = false, error = err.message) }, ) @@ -371,7 +345,6 @@ object PlayerStreamsRepository { val pluginJobs = pluginScrapers.map { scraper -> async { - log.d { "fetch $panelName request=$requestKey plugin=${scraper.name}" } PluginRepository.executeScraper( scraper = scraper, tmdbId = pluginContentId( @@ -384,7 +357,6 @@ object PlayerStreamsRepository { episode = episode, ).fold( onSuccess = { results -> - log.d { "fetched $panelName request=$requestKey plugin=${scraper.name} streams=${results.size}" } AddonStreamGroup( addonName = scraper.name, addonId = "plugin:${scraper.id}", @@ -393,7 +365,7 @@ object PlayerStreamsRepository { ) }, onFailure = { err -> - log.w(err) { "failed $panelName request=$requestKey plugin=${scraper.name}" } + log.w(err) { "Plugin scraper failed: ${scraper.name}" } AddonStreamGroup( addonName = scraper.name, addonId = "plugin:${scraper.id}", @@ -420,7 +392,6 @@ object PlayerStreamsRepository { for (availabilityJob in debridAvailabilityJobs) { availabilityJob.join() } - log.d { "complete $panelName request=$requestKey ${stateFlow.value.streamDiagnostics()}" } launch { DirectDebridStreamPreparer.prepare( streams = stateFlow.value.groups @@ -455,25 +426,6 @@ private data class PlayerInstalledStreamAddonTarget( val manifest: com.nuvio.app.features.addons.AddonManifest, ) -private fun StreamsUiState.streamDiagnostics(): String { - val streamCount = groups.sumOf { it.streams.size } - val loadingCount = groups.count { it.isLoading } - val errorCount = groups.count { !it.error.isNullOrBlank() } - val sampleGroups = groups.take(4).joinToString(prefix = "[", postfix = "]") { group -> - buildString { - append(group.addonName) - append(':') - append(group.streams.size) - if (group.isLoading) append(":loading") - if (!group.error.isNullOrBlank()) append(":error") - } - } - val suffix = if (groups.size > 4) "+${groups.size - 4}" else "" - return "groups=${groups.size} streams=$streamCount isAnyLoading=$isAnyLoading " + - "loadingGroups=$loadingCount errorGroups=$errorCount empty=${emptyStateReason ?: "none"} " + - "sample=$sampleGroups$suffix" -} - private fun com.nuvio.app.features.addons.ManagedAddon.streamAddonInstanceId(manifestId: String): String = "addon:$manifestId:$manifestUrl" diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/NextEpisodeCard.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/NextEpisodeCard.kt index dcb5b496..2de18276 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/NextEpisodeCard.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/NextEpisodeCard.kt @@ -37,7 +37,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage +import coil3.compose.AsyncImage import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.compose_player_episode_title_format import nuvio.composeapp.generated.resources.detail_btn_play diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileEditScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileEditScreen.kt index b6a8524b..5f00697d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileEditScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileEditScreen.kt @@ -45,7 +45,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage +import coil3.compose.AsyncImage import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.ui.NuvioInputField diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt index c2033993..ab6b5b2b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt @@ -38,9 +38,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString @@ -65,7 +62,6 @@ object ProfileRepository { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val log = Logger.withTag("ProfileRepository") private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } - private val profileSwitchMutex = Mutex() private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) } private val _state = MutableStateFlow(ProfileState()) @@ -145,15 +141,7 @@ object ProfileRepository { } } - suspend fun switchToProfile(profileIndex: Int) { - profileSwitchMutex.withLock { - withContext(Dispatchers.Default) { - selectProfile(profileIndex) - } - } - } - - private fun selectProfile(profileIndex: Int) { + fun selectProfile(profileIndex: Int) { activeProfileIndex = profileIndex val selectedProfile = _state.value.profiles.find { it.profileIndex == profileIndex } _state.value = _state.value.copy( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt index da6c96b9..195ba674 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt @@ -56,7 +56,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage +import coil3.compose.AsyncImage import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState import kotlinx.coroutines.delay @@ -176,6 +176,7 @@ fun ProfileSelectionScreen( } else if (profile.pinEnabled) { pinDialogProfile = profile } else { + ProfileRepository.selectProfile(profile.profileIndex) onProfileSelected(profile) } }, @@ -216,6 +217,7 @@ fun ProfileSelectionScreen( } else if (profile.pinEnabled) { pinDialogProfile = profile } else { + ProfileRepository.selectProfile(profile.profileIndex) onProfileSelected(profile) } }, @@ -280,6 +282,7 @@ fun ProfileSelectionScreen( onVerify = { pin -> ProfileRepository.verifyPin(profile.profileIndex, pin) }, onVerified = { pinDialogProfile = null + ProfileRepository.selectProfile(profile.profileIndex) onProfileSelected(profile) }, onDismiss = { pinDialogProfile = null }, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt index 81545f14..5e812b28 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt @@ -66,7 +66,7 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage +import coil3.compose.AsyncImage import com.nuvio.app.core.ui.NuvioTokens import com.nuvio.app.core.ui.nuvio import com.nuvio.app.isIos @@ -85,7 +85,6 @@ fun ProfileSwitcherTab( onProfileSelected: (NuvioProfile) -> Unit, onAddProfileRequested: () -> Unit, triggerContent: (@Composable (selected: Boolean) -> Unit)? = null, - openPopupOnClick: Boolean = false, modifier: Modifier = Modifier, ) { val tokens = MaterialTheme.nuvio @@ -202,13 +201,7 @@ fun ProfileSwitcherTab( .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, - onClick = { - if (openPopupOnClick && profiles.isNotEmpty()) { - showPopup = true - } else { - onClick() - } - }, + onClick = onClick, ) .pointerInput(profiles) { detectDragGesturesAfterLongPress( @@ -343,358 +336,6 @@ fun ProfileSwitcherTab( } } -@Composable -fun SidebarProfileSwitcherStack( - onProfileSelected: (NuvioProfile) -> Unit, - onAddProfileRequested: () -> Unit, - onDismissRequest: () -> Unit, - modifier: Modifier = Modifier, -) { - val profileState by ProfileRepository.state.collectAsStateWithLifecycle() - val activeProfile = profileState.activeProfile - val profiles = profileState.profiles - val avatars by AvatarRepository.avatars.collectAsStateWithLifecycle() - var pinProfile by remember { mutableStateOf(null) } - - LaunchedEffect(Unit) { - AvatarRepository.fetchAvatars() - AvatarRepository.refreshAvatars() - } - - fun chooseProfile(profile: NuvioProfile) { - if (profile.profileIndex == activeProfile?.profileIndex) { - onDismissRequest() - return - } - if (profile.pinEnabled) { - pinProfile = profile - } else { - onProfileSelected(profile) - onDismissRequest() - } - } - - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - profiles.forEach { profile -> - SidebarProfileSwitcherRow( - profile = profile, - avatars = avatars, - isActive = profile.profileIndex == activeProfile?.profileIndex, - onClick = { chooseProfile(profile) }, - ) - } - - if (profiles.size < 4) { - SidebarAddProfileRow( - onClick = { - onDismissRequest() - onAddProfileRequested() - }, - ) - } - - AnimatedVisibility( - visible = pinProfile != null, - enter = expandVertically() + fadeIn(tween(160)), - exit = shrinkVertically(tween(120)) + fadeOut(tween(90)), - ) { - pinProfile?.let { profile -> - SidebarCompactPinEntry( - profileName = profile.name, - onVerified = { - onProfileSelected(profile) - pinProfile = null - onDismissRequest() - }, - onCancel = { pinProfile = null }, - verifyPin = { pin -> - ProfileRepository.verifyPin(profile.profileIndex, pin) - }, - ) - } - } - } -} - -@Composable -private fun SidebarProfileSwitcherRow( - profile: NuvioProfile, - avatars: List, - isActive: Boolean, - onClick: () -> Unit, -) { - val tokens = MaterialTheme.nuvio - Row( - modifier = Modifier - .fillMaxWidth() - .height(40.dp) - .clip(tokens.shapes.compactCard) - .background( - if (isActive) { - tokens.colors.overlaySelected - } else { - tokens.colors.surface.copy(alpha = 0f) - }, - ) - .clickable(onClick = onClick) - .padding(horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier.size(30.dp), - contentAlignment = Alignment.Center, - ) { - ActiveProfileMiniAvatar( - profile = profile, - avatars = avatars, - selected = isActive, - size = 26, - ) - } - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = profile.name.ifBlank { - stringResource(Res.string.profile_label_number, profile.profileIndex) - }, - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.labelMedium, - color = if (isActive) tokens.colors.textPrimary else tokens.colors.textMuted, - fontWeight = if (isActive) FontWeight.Bold else FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - if (profile.pinEnabled) { - Icon( - imageVector = Icons.Rounded.Lock, - contentDescription = null, - tint = tokens.colors.textMuted, - modifier = Modifier.size(12.dp), - ) - } - } -} - -@Composable -private fun SidebarAddProfileRow( - onClick: () -> Unit, -) { - val tokens = MaterialTheme.nuvio - Row( - modifier = Modifier - .fillMaxWidth() - .height(40.dp) - .clip(tokens.shapes.compactCard) - .clickable(onClick = onClick) - .padding(horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier - .size(30.dp) - .clip(tokens.shapes.avatar) - .background(tokens.colors.surfaceCard), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Rounded.Add, - contentDescription = stringResource(Res.string.compose_profile_add_profile), - tint = tokens.colors.textMuted, - modifier = Modifier.size(16.dp), - ) - } - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(Res.string.compose_profile_add_profile), - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.labelMedium, - color = tokens.colors.textMuted, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } -} - -@Composable -private fun SidebarCompactPinEntry( - profileName: String, - onVerified: () -> Unit, - onCancel: () -> Unit, - verifyPin: suspend (String) -> PinVerifyResult, -) { - val tokens = MaterialTheme.nuvio - var pin by remember(profileName) { mutableStateOf("") } - var error by remember(profileName) { mutableStateOf(null) } - var isVerifying by remember(profileName) { mutableStateOf(false) } - val scope = rememberCoroutineScope() - val haptic = LocalHapticFeedback.current - - Column( - modifier = Modifier - .fillMaxWidth() - .clip(tokens.shapes.compactCard) - .background(tokens.colors.surfaceCard.copy(alpha = 0.72f)) - .padding(8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = stringResource(Res.string.pin_enter_for, profileName), - style = MaterialTheme.typography.labelSmall, - color = tokens.colors.textMuted, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - repeat(4) { index -> - val filled = index < pin.length - Box( - modifier = Modifier - .size(8.dp) - .clip(tokens.shapes.avatar) - .then( - if (filled) { - Modifier.background(tokens.colors.accent) - } else { - Modifier.border(tokens.borders.thin, tokens.colors.borderDefault, tokens.shapes.avatar) - }, - ), - ) - } - } - - AnimatedVisibility( - visible = error != null, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut(), - ) { - Text( - text = error.orEmpty(), - style = MaterialTheme.typography.labelSmall, - color = tokens.colors.danger, - textAlign = TextAlign.Center, - maxLines = 2, - modifier = Modifier.padding(top = 6.dp), - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - SidebarCompactPinKeypad( - onDigit = { digit -> - if (pin.length < 4 && !isVerifying) { - error = null - pin += digit - haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) - if (pin.length == 4) { - isVerifying = true - scope.launch { - val result = verifyPin(pin) - if (result.unlocked) { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - onVerified() - } else { - error = if (result.retryAfterSeconds > 0) { - getString(Res.string.pin_locked_try_again, result.retryAfterSeconds) - } else { - getString(Res.string.pin_incorrect) - } - pin = "" - } - isVerifying = false - } - } - } - }, - onBackspace = { - if (pin.isNotEmpty() && !isVerifying) { - pin = pin.dropLast(1) - error = null - } - }, - ) - - Text( - text = stringResource(Res.string.pin_cancel), - style = MaterialTheme.typography.labelSmall, - color = tokens.colors.accent, - fontWeight = FontWeight.SemiBold, - modifier = Modifier - .clip(tokens.shapes.compactCard) - .clickable(onClick = onCancel) - .padding(horizontal = 8.dp, vertical = 4.dp), - ) - } -} - -@Composable -private fun SidebarCompactPinKeypad( - onDigit: (String) -> Unit, - onBackspace: () -> Unit, -) { - val tokens = MaterialTheme.nuvio - val rows = listOf( - listOf("1", "2", "3"), - listOf("4", "5", "6"), - listOf("7", "8", "9"), - listOf("", "0", "⌫"), - ) - - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - rows.forEach { row -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), - ) { - row.forEach { key -> - when (key) { - "" -> Spacer(modifier = Modifier.size(30.dp)) - "⌫" -> { - Box( - modifier = Modifier - .size(30.dp) - .clip(tokens.shapes.avatar) - .background(tokens.colors.surface) - .clickable(onClick = onBackspace), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.Backspace, - contentDescription = stringResource(Res.string.pin_backspace), - tint = tokens.colors.textPrimary, - modifier = Modifier.size(14.dp), - ) - } - } - else -> { - Box( - modifier = Modifier - .size(30.dp) - .clip(tokens.shapes.avatar) - .background(tokens.colors.surface) - .clickable { onDigit(key) }, - contentAlignment = Alignment.Center, - ) { - Text( - text = key, - style = MaterialTheme.typography.labelLarge, - color = tokens.colors.textPrimary, - fontWeight = FontWeight.Medium, - ) - } - } - } - } - } - } - } -} - @Composable private fun PopupAddProfileBubble( delayMs: Int, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt index 2df20248..2cf53cba 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt @@ -30,7 +30,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage +import coil3.compose.AsyncImage import com.nuvio.app.core.network.NetworkCondition import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.core.ui.NuvioDropdownChip diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt index 7b8f781a..c2603126 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt @@ -83,7 +83,6 @@ import org.jetbrains.compose.resources.stringResource @Composable fun SearchScreen( modifier: Modifier = Modifier, - topChromePadding: Dp? = null, onPosterClick: ((MetaPreview) -> Unit)? = null, onPosterLongClick: ((MetaPreview) -> Unit)? = null, searchFocusRequestCount: Int = 0, @@ -239,7 +238,6 @@ fun SearchScreen( NuvioScreen( horizontalPadding = 0.dp, - topPadding = if (topChromePadding != null) 0.dp else null, listState = listState, modifier = Modifier.fillMaxSize(), ) { @@ -257,7 +255,6 @@ fun SearchScreen( NuvioScreenHeader( title = headerTitle, modifier = Modifier.padding(horizontal = 16.dp), - topPadding = topChromePadding, ) androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(6.dp)) androidx.compose.foundation.layout.Box(modifier = Modifier.padding(horizontal = 16.dp)) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt index 07bb324b..157bed8e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt @@ -40,8 +40,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.nuvio.app.isDesktop import com.nuvio.app.core.ui.AppTheme import com.nuvio.app.core.ui.NuvioBottomSheetActionRow import com.nuvio.app.core.ui.NuvioBottomSheetDivider @@ -59,8 +57,6 @@ import nuvio.composeapp.generated.resources.settings_appearance_app_language_she import nuvio.composeapp.generated.resources.settings_appearance_amoled_black import nuvio.composeapp.generated.resources.settings_appearance_amoled_description import nuvio.composeapp.generated.resources.settings_appearance_continue_watching_description -import nuvio.composeapp.generated.resources.settings_appearance_desktop_navigation -import nuvio.composeapp.generated.resources.settings_appearance_desktop_navigation_sheet_title import nuvio.composeapp.generated.resources.settings_appearance_liquid_glass import nuvio.composeapp.generated.resources.settings_appearance_liquid_glass_description import nuvio.composeapp.generated.resources.settings_appearance_poster_customization_description @@ -139,11 +135,6 @@ internal fun LazyListScope.appearanceSettingsContent( } item { var showLanguageSheet by remember { mutableStateOf(false) } - var showDesktopNavigationSheet by remember { mutableStateOf(false) } - val desktopNavigationLayout by remember { - ThemeSettingsRepository.ensureLoaded() - ThemeSettingsRepository.desktopNavigationLayout - }.collectAsStateWithLifecycle() SettingsSection( title = stringResource(Res.string.settings_appearance_section_display), isTablet = isTablet, @@ -166,16 +157,6 @@ internal fun LazyListScope.appearanceSettingsContent( onCheckedChange = onLiquidGlassNativeTabBarToggle, ) } - if (isDesktop) { - SettingsGroupDivider(isTablet = isTablet) - SettingsNavigationRow( - title = stringResource(Res.string.settings_appearance_desktop_navigation), - description = stringResource(desktopNavigationLayout.labelRes), - icon = Icons.Rounded.Style, - isTablet = isTablet, - onClick = { showDesktopNavigationSheet = true }, - ) - } SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( title = stringResource(Res.string.settings_appearance_app_language), @@ -187,17 +168,6 @@ internal fun LazyListScope.appearanceSettingsContent( } } - if (showDesktopNavigationSheet) { - DesktopNavigationLayoutBottomSheet( - selectedLayout = desktopNavigationLayout, - onLayoutSelected = { - ThemeSettingsRepository.setDesktopNavigationLayout(it) - showDesktopNavigationSheet = false - }, - onDismiss = { showDesktopNavigationSheet = false }, - ) - } - if (showLanguageSheet) { AppearanceLanguageBottomSheet( selectedLanguage = selectedAppLanguage, @@ -236,66 +206,6 @@ internal fun LazyListScope.appearanceSettingsContent( } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun DesktopNavigationLayoutBottomSheet( - selectedLayout: DesktopNavigationLayout, - onLayoutSelected: (DesktopNavigationLayout) -> Unit, - onDismiss: () -> Unit, -) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val coroutineScope = rememberCoroutineScope() - - NuvioModalBottomSheet( - onDismissRequest = { - coroutineScope.launch { - dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss) - } - }, - sheetState = sheetState, - ) { - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp), - ) { - item { - Text( - text = stringResource(Res.string.settings_appearance_desktop_navigation_sheet_title), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), - ) - } - - itemsIndexed(DesktopNavigationLayout.entries) { index, layout -> - if (index > 0) { - NuvioBottomSheetDivider() - } - NuvioBottomSheetActionRow( - title = stringResource(layout.labelRes), - onClick = { - onLayoutSelected(layout) - coroutineScope.launch { - dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss) - } - }, - trailingContent = { - if (layout == selectedLayout) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = stringResource(Res.string.cd_selected), - tint = MaterialTheme.colorScheme.primary, - ) - } - }, - ) - } - } - } -} - private data class AppLanguageSheetOption( val language: AppLanguage, val labelRes: StringResource, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DesktopNavigationLayout.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DesktopNavigationLayout.kt deleted file mode 100644 index 507a8ea5..00000000 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DesktopNavigationLayout.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.nuvio.app.features.settings - -import nuvio.composeapp.generated.resources.Res -import nuvio.composeapp.generated.resources.settings_appearance_desktop_navigation_sidebar -import nuvio.composeapp.generated.resources.settings_appearance_desktop_navigation_top_bar -import org.jetbrains.compose.resources.StringResource - -enum class DesktopNavigationLayout( - val labelRes: StringResource, -) { - Sidebar(Res.string.settings_appearance_desktop_navigation_sidebar), - TopBar(Res.string.settings_appearance_desktop_navigation_top_bar), - ; - - companion object { - val Default = Sidebar - - fun fromName(name: String?): DesktopNavigationLayout = - entries.firstOrNull { it.name.equals(name, ignoreCase = true) } ?: Default - } -} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/LicensesAttributionsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/LicensesAttributionsPage.kt index e58a91f7..d86eb0a6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/LicensesAttributionsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/LicensesAttributionsPage.kt @@ -28,7 +28,7 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage +import coil3.compose.AsyncImage import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.features.cloud.PremiumizeCloudLibraryPosterUrl diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt index c882944e..f557f762 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt @@ -21,9 +21,8 @@ import androidx.compose.material3.Text import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import com.nuvio.app.core.build.AppVersionPolicy +import com.nuvio.app.core.build.AppVersionConfig import nuvio.composeapp.generated.resources.Res -import nuvio.composeapp.generated.resources.compose_about_based_on_version_format import nuvio.composeapp.generated.resources.compose_about_made_with import nuvio.composeapp.generated.resources.compose_about_version_format import nuvio.composeapp.generated.resources.compose_settings_page_account @@ -76,8 +75,6 @@ internal fun LazyListScope.settingsRootContent( onDownloadsClick: () -> Unit, onAccountClick: () -> Unit, onSwitchProfileClick: (() -> Unit)? = null, - showDownloadsEntry: Boolean = true, - showNotificationsEntry: Boolean = true, showAccountSection: Boolean = true, showGeneralSection: Boolean = true, showAboutSection: Boolean = true, @@ -141,16 +138,14 @@ internal fun LazyListScope.settingsRootContent( isTablet = isTablet, onClick = onContentDiscoveryClick, ) - if (showDownloadsEntry) { - SettingsGroupDivider(isTablet = isTablet) - SettingsNavigationRow( - title = stringResource(Res.string.compose_settings_root_downloads_title), - description = stringResource(Res.string.compose_settings_root_downloads_description), - icon = Icons.Rounded.CloudDownload, - isTablet = isTablet, - onClick = onDownloadsClick, - ) - } + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = stringResource(Res.string.compose_settings_root_downloads_title), + description = stringResource(Res.string.compose_settings_root_downloads_description), + icon = Icons.Rounded.CloudDownload, + isTablet = isTablet, + onClick = onDownloadsClick, + ) SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( title = stringResource(Res.string.compose_settings_page_playback), @@ -175,16 +170,14 @@ internal fun LazyListScope.settingsRootContent( isTablet = isTablet, onClick = onIntegrationsClick, ) - if (showNotificationsEntry) { - SettingsGroupDivider(isTablet = isTablet) - SettingsNavigationRow( - title = stringResource(Res.string.compose_settings_page_notifications), - description = stringResource(Res.string.compose_settings_root_notifications_description), - icon = Icons.Rounded.Notifications, - isTablet = isTablet, - onClick = onNotificationsClick, - ) - } + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = stringResource(Res.string.compose_settings_page_notifications), + description = stringResource(Res.string.compose_settings_root_notifications_description), + icon = Icons.Rounded.Notifications, + isTablet = isTablet, + onClick = onNotificationsClick, + ) } } } @@ -259,26 +252,14 @@ internal fun LazyListScope.settingsRootContent( Text( text = stringResource( Res.string.compose_about_version_format, - AppVersionPolicy.displayVersionName, - AppVersionPolicy.displayVersionCode, + AppVersionConfig.VERSION_NAME, + AppVersionConfig.VERSION_CODE, ), modifier = Modifier.fillMaxWidth(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) - AppVersionPolicy.basedOnVersionName?.let { basedOnVersionName -> - Text( - text = stringResource( - Res.string.compose_about_based_on_version_format, - basedOnVersionName, - ), - modifier = Modifier.fillMaxWidth(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - } } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index 35566fe4..b356e986 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -217,12 +217,6 @@ fun SettingsScreen( val page = remember(currentPage) { SettingsPage.valueOf(currentPage) } val previousPage = page.previousPage() - LaunchedEffect(page) { - if (!page.isEnabledByFeaturePolicy()) { - currentPage = SettingsPage.Root.name - } - } - LaunchedEffect(rootActionRequests, rootActionsEnabled, page) { rootActionRequests.collect { if (!rootActionsEnabled) return@collect @@ -240,9 +234,7 @@ fun SettingsScreen( ?.let { runCatching { SettingsPage.valueOf(it) }.getOrNull() } ?: return@LaunchedEffect if (!rootActionsEnabled) return@LaunchedEffect - if (targetPage.isEnabledByFeaturePolicy()) { - currentPage = targetPage.name - } + currentPage = targetPage.name onRequestedPageConsumed() } @@ -440,8 +432,6 @@ private fun MobileSettingsScreen( } val searchEntries = settingsSearchEntries( pluginsEnabled = AppFeaturePolicy.pluginsEnabled, - downloadsEnabled = AppFeaturePolicy.downloadsEnabled, - notificationsEnabled = AppFeaturePolicy.notificationsEnabled, liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported, switchProfileAvailable = onSwitchProfile != null, checkForUpdatesAvailable = onCheckForUpdatesClick != null, @@ -464,11 +454,7 @@ private fun MobileSettingsScreen( SettingsPage.MetaScreen -> onMetaScreenClick() else -> onPageChange(target.page) } - SettingsSearchTarget.Downloads -> { - if (AppFeaturePolicy.downloadsEnabled) { - onDownloadsClick() - } - } + SettingsSearchTarget.Downloads -> onDownloadsClick() SettingsSearchTarget.Collections -> onCollectionsClick() SettingsSearchTarget.SwitchProfile -> onSwitchProfile?.invoke() SettingsSearchTarget.CheckForUpdates -> onCheckForUpdatesClick?.invoke() @@ -528,8 +514,6 @@ private fun MobileSettingsScreen( onDownloadsClick = onDownloadsClick, onAccountClick = onAccountClick, onSwitchProfileClick = onSwitchProfile, - showDownloadsEntry = AppFeaturePolicy.downloadsEnabled, - showNotificationsEntry = AppFeaturePolicy.notificationsEnabled, ) } } @@ -580,12 +564,10 @@ private fun MobileSettingsScreen( isTablet = false, rememberLastProfileEnabled = rememberLastProfileEnabled, ) - SettingsPage.Notifications -> if (AppFeaturePolicy.notificationsEnabled) { - notificationsSettingsContent( - isTablet = false, - uiState = episodeReleaseNotificationsUiState, - ) - } + SettingsPage.Notifications -> notificationsSettingsContent( + isTablet = false, + uiState = episodeReleaseNotificationsUiState, + ) SettingsPage.ContinueWatching -> continueWatchingSettingsContent( isTablet = false, isVisible = continueWatchingPreferencesUiState.isVisible, @@ -653,13 +635,6 @@ private fun MobileSettingsScreen( } } -private fun SettingsPage.isEnabledByFeaturePolicy(): Boolean = - when (this) { - SettingsPage.Notifications -> AppFeaturePolicy.notificationsEnabled - SettingsPage.Plugins -> AppFeaturePolicy.pluginsEnabled - else -> true - } - @Composable private fun rememberSettingsRootSearchRevealConnection( page: SettingsPage, @@ -820,8 +795,6 @@ private fun TabletSettingsScreen( val hapticScope = rememberCoroutineScope() val searchEntries = settingsSearchEntries( pluginsEnabled = AppFeaturePolicy.pluginsEnabled, - downloadsEnabled = AppFeaturePolicy.downloadsEnabled, - notificationsEnabled = AppFeaturePolicy.notificationsEnabled, liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported, switchProfileAvailable = onSwitchProfile != null, checkForUpdatesAvailable = onCheckForUpdatesClick != null, @@ -829,16 +802,8 @@ private fun TabletSettingsScreen( fun openSearchTarget(target: SettingsSearchTarget) { when (target) { - is SettingsSearchTarget.Page -> { - if (target.page.isEnabledByFeaturePolicy()) { - openInlinePage(target.page) - } - } - SettingsSearchTarget.Downloads -> { - if (AppFeaturePolicy.downloadsEnabled) { - onDownloadsClick() - } - } + is SettingsSearchTarget.Page -> openInlinePage(target.page) + SettingsSearchTarget.Downloads -> onDownloadsClick() SettingsSearchTarget.Collections -> onCollectionsClick() SettingsSearchTarget.SwitchProfile -> onSwitchProfile?.invoke() SettingsSearchTarget.CheckForUpdates -> onCheckForUpdatesClick?.invoke() @@ -928,8 +893,6 @@ private fun TabletSettingsScreen( onDownloadsClick = onDownloadsClick, onAccountClick = { openInlinePage(SettingsPage.Account) }, onSwitchProfileClick = onSwitchProfile, - showDownloadsEntry = AppFeaturePolicy.downloadsEnabled, - showNotificationsEntry = AppFeaturePolicy.notificationsEnabled, showAccountSection = activeCategory == SettingsCategory.Account, showGeneralSection = activeCategory == SettingsCategory.General, showAboutSection = activeCategory == SettingsCategory.About, @@ -984,12 +947,10 @@ private fun TabletSettingsScreen( isTablet = true, rememberLastProfileEnabled = rememberLastProfileEnabled, ) - SettingsPage.Notifications -> if (AppFeaturePolicy.notificationsEnabled) { - notificationsSettingsContent( - isTablet = true, - uiState = episodeReleaseNotificationsUiState, - ) - } + SettingsPage.Notifications -> notificationsSettingsContent( + isTablet = true, + uiState = episodeReleaseNotificationsUiState, + ) SettingsPage.ContinueWatching -> continueWatchingSettingsContent( isTablet = true, isVisible = continueWatchingPreferencesUiState.isVisible, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt index b500ad0d..5d0f8cf3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt @@ -79,8 +79,6 @@ internal data class SettingsSearchEntry( @Composable internal fun settingsSearchEntries( pluginsEnabled: Boolean, - downloadsEnabled: Boolean, - notificationsEnabled: Boolean, liquidGlassNativeTabBarSupported: Boolean, switchProfileAvailable: Boolean, checkForUpdatesAvailable: Boolean, @@ -227,16 +225,14 @@ internal fun settingsSearchEntries( description = stringResource(Res.string.compose_settings_root_content_discovery_description), icon = Icons.Rounded.Extension, ) - if (downloadsEnabled) { - add( - key = "downloads", - title = downloadsPage, - description = stringResource(Res.string.compose_settings_root_downloads_description), - category = generalCategory, - icon = Icons.Rounded.CloudDownload, - target = SettingsSearchTarget.Downloads, - ) - } + add( + key = "downloads", + title = downloadsPage, + description = stringResource(Res.string.compose_settings_root_downloads_description), + category = generalCategory, + icon = Icons.Rounded.CloudDownload, + target = SettingsSearchTarget.Downloads, + ) addPage( page = SettingsPage.Playback, key = "playback", @@ -258,15 +254,13 @@ internal fun settingsSearchEntries( description = stringResource(Res.string.compose_settings_root_integrations_description), icon = Icons.Rounded.Link, ) - if (notificationsEnabled) { - addPage( - page = SettingsPage.Notifications, - key = "notifications", - title = notificationsPage, - description = stringResource(Res.string.compose_settings_root_notifications_description), - icon = Icons.Rounded.Notifications, - ) - } + addPage( + page = SettingsPage.Notifications, + key = "notifications", + title = notificationsPage, + description = stringResource(Res.string.compose_settings_root_notifications_description), + icon = Icons.Rounded.Notifications, + ) addPage( page = SettingsPage.SupportersContributors, key = "supporters", @@ -800,26 +794,24 @@ internal fun settingsSearchEntries( ) } - if (notificationsEnabled) { - val notificationsAlerts = stringResource(Res.string.settings_notifications_section_alerts) - addRow( - page = SettingsPage.Notifications, - key = "episode-release-alerts", - title = stringResource(Res.string.settings_notifications_episode_release_alerts), - description = stringResource(Res.string.settings_notifications_episode_release_alerts_description), - pageLabel = notificationsPage, - section = notificationsAlerts, - icon = Icons.Rounded.Notifications, - ) - addRow( - page = SettingsPage.Notifications, - key = "notification-test", - title = stringResource(Res.string.settings_notifications_test_title), - pageLabel = notificationsPage, - section = stringResource(Res.string.settings_notifications_section_test), - icon = Icons.Rounded.Notifications, - ) - } + val notificationsAlerts = stringResource(Res.string.settings_notifications_section_alerts) + addRow( + page = SettingsPage.Notifications, + key = "episode-release-alerts", + title = stringResource(Res.string.settings_notifications_episode_release_alerts), + description = stringResource(Res.string.settings_notifications_episode_release_alerts_description), + pageLabel = notificationsPage, + section = notificationsAlerts, + icon = Icons.Rounded.Notifications, + ) + addRow( + page = SettingsPage.Notifications, + key = "notification-test", + title = stringResource(Res.string.settings_notifications_test_title), + pageLabel = notificationsPage, + section = stringResource(Res.string.settings_notifications_section_test), + icon = Icons.Rounded.Notifications, + ) addRow( page = SettingsPage.TraktAuthentication, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SupportersContributorsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SupportersContributorsPage.kt index 664cfd36..3b3f8056 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SupportersContributorsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SupportersContributorsPage.kt @@ -49,7 +49,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage +import coil3.compose.AsyncImage import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.core.ui.NuvioSurfaceCard diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt index 956dc217..431b9d20 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt @@ -17,9 +17,6 @@ object ThemeSettingsRepository { private val _liquidGlassNativeTabBarEnabled = MutableStateFlow(false) val liquidGlassNativeTabBarEnabled: StateFlow = _liquidGlassNativeTabBarEnabled.asStateFlow() - private val _desktopNavigationLayout = MutableStateFlow(DesktopNavigationLayout.Default) - val desktopNavigationLayout: StateFlow = _desktopNavigationLayout.asStateFlow() - private val _selectedAppLanguage = MutableStateFlow(AppLanguage.ENGLISH) val selectedAppLanguage: StateFlow = _selectedAppLanguage.asStateFlow() @@ -39,7 +36,6 @@ object ThemeSettingsRepository { _selectedTheme.value = AppTheme.WHITE _amoledEnabled.value = false _liquidGlassNativeTabBarEnabled.value = false - _desktopNavigationLayout.value = DesktopNavigationLayout.Default NativeTabBridge.publishAccentColor(AppTheme.WHITE.nativeTabAccentHex()) NativeTabBridge.publishLiquidGlassEnabled(false) _selectedAppLanguage.value = AppLanguage.ENGLISH @@ -63,9 +59,6 @@ object ThemeSettingsRepository { val liquidGlassEnabled = ThemeSettingsStorage.loadLiquidGlassNativeTabBarEnabled() ?: false _liquidGlassNativeTabBarEnabled.value = liquidGlassEnabled NativeTabBridge.publishLiquidGlassEnabled(liquidGlassEnabled) - _desktopNavigationLayout.value = DesktopNavigationLayout.fromName( - ThemeSettingsStorage.loadDesktopNavigationLayout(), - ) val appLanguage = AppLanguage.fromCode(ThemeSettingsStorage.loadSelectedAppLanguage()) ThemeSettingsStorage.applySelectedAppLanguage(appLanguage.code) _selectedAppLanguage.value = appLanguage @@ -94,13 +87,6 @@ object ThemeSettingsRepository { NativeTabBridge.publishLiquidGlassEnabled(enabled) } - fun setDesktopNavigationLayout(layout: DesktopNavigationLayout) { - ensureLoaded() - if (_desktopNavigationLayout.value == layout) return - _desktopNavigationLayout.value = layout - ThemeSettingsStorage.saveDesktopNavigationLayout(layout.name) - } - fun setAppLanguage(language: AppLanguage) { ensureLoaded() if (_selectedAppLanguage.value == language) return diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt index 5bb6a284..2a788baf 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt @@ -9,8 +9,6 @@ internal expect object ThemeSettingsStorage { fun saveAmoledEnabled(enabled: Boolean) fun loadLiquidGlassNativeTabBarEnabled(): Boolean? fun saveLiquidGlassNativeTabBarEnabled(enabled: Boolean) - fun loadDesktopNavigationLayout(): String? - fun saveDesktopNavigationLayout(layoutName: String) fun loadSelectedAppLanguage(): String? fun saveSelectedAppLanguage(languageCode: String) fun applySelectedAppLanguage(languageCode: String) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeChip.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeChip.kt index 08241f19..f0e9ebd2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeChip.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeChip.kt @@ -19,7 +19,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp -import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage +import coil3.compose.AsyncImage import com.nuvio.app.core.i18n.localizedByteUnit import com.nuvio.app.core.ui.NuvioTokens import com.nuvio.app.core.ui.nuvio diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt index f9138cc1..3bdaa37a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt @@ -77,19 +77,17 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString -import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.core.ui.NuvioBackButton import com.nuvio.app.core.ui.NuvioBottomSheetActionRow import com.nuvio.app.core.ui.NuvioBottomSheetDivider import com.nuvio.app.core.ui.NuvioModalBottomSheet import com.nuvio.app.core.ui.NuvioToastController import com.nuvio.app.core.ui.dismissNuvioBottomSheet -import com.nuvio.app.core.ui.secondaryClick import com.nuvio.app.features.downloads.DownloadsRepository import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.rememberModalBottomSheetState -import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage +import coil3.compose.AsyncImage import com.nuvio.app.core.ui.nuvioSafeBottomPadding import com.nuvio.app.features.debrid.DebridProviders import com.nuvio.app.features.debrid.DebridSettingsRepository @@ -146,9 +144,7 @@ fun StreamsScreen( WatchProgressRepository.uiState }.collectAsStateWithLifecycle() remember { - if (AppFeaturePolicy.downloadsEnabled) { - DownloadsRepository.ensureLoaded() - } + DownloadsRepository.ensureLoaded() } val isEpisode = seasonNumber != null && episodeNumber != null val clipboardManager = LocalClipboardManager.current @@ -347,7 +343,6 @@ fun StreamsScreen( StreamActionsSheet( stream = streamActionsTarget, externalPlayerEnabled = playerSettings.externalPlayerEnabled, - showDownloadAction = AppFeaturePolicy.downloadsEnabled, onDismiss = { streamActionsTarget = null }, onCopyLink = { stream -> val directUrl = stream.playableDirectUrl @@ -1032,7 +1027,6 @@ private fun StreamCard( onClick = onClick, onLongClick = onLongClick, ) - .secondaryClick(if (enabled) onLongClick else null) .padding(14.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -1182,7 +1176,6 @@ private fun StreamNameWithInstantService( private fun StreamActionsSheet( stream: StreamItem?, externalPlayerEnabled: Boolean, - showDownloadAction: Boolean, onDismiss: () -> Unit, onCopyLink: (StreamItem) -> Unit, onDownload: (StreamItem) -> Unit, @@ -1261,19 +1254,17 @@ private fun StreamActionsSheet( } }, ) - if (showDownloadAction) { - NuvioBottomSheetDivider() - NuvioBottomSheetActionRow( - icon = Icons.Rounded.Download, - title = stringResource(Res.string.streams_download_file), - onClick = { - onDownload(stream) - coroutineScope.launch { - dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss) - } - }, - ) - } + NuvioBottomSheetDivider() + NuvioBottomSheetActionRow( + icon = Icons.Rounded.Download, + title = stringResource(Res.string.streams_download_file), + onClick = { + onDownload(stream) + coroutineScope.launch { + dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss) + } + }, + ) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt index ff5ab11b..5ed13ae7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt @@ -40,7 +40,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage +import coil3.compose.AsyncImage import com.nuvio.app.isIos import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeSource diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt index 5c461349..d4e45e9e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt @@ -1,7 +1,7 @@ package com.nuvio.app.features.trakt import co.touchlab.kermit.Logger -import com.nuvio.app.core.build.AppVersionPolicy +import com.nuvio.app.core.build.AppVersionConfig import com.nuvio.app.features.addons.httpRequestRaw import com.nuvio.app.features.profiles.ProfileRepository import kotlinx.coroutines.CancellationException @@ -255,7 +255,7 @@ internal object TraktScrobbleRepository { ids = item.ids.toRequestBodyOrNull(), ), progress = clampedProgress, - appVersion = AppVersionPolicy.displayVersionName, + appVersion = AppVersionConfig.VERSION_NAME, ) is TraktScrobbleItem.Episode -> TraktScrobbleRequest( @@ -270,7 +270,7 @@ internal object TraktScrobbleRepository { number = item.number, ), progress = clampedProgress, - appVersion = AppVersionPolicy.displayVersionName, + appVersion = AppVersionConfig.VERSION_NAME, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt index b1a681b5..13e2ebd5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt @@ -8,6 +8,7 @@ import com.nuvio.app.features.trakt.parseTraktIsoDateTimeToEpochMs import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource +import co.touchlab.kermit.Logger @Composable fun computeAirDateBadgeText( @@ -66,32 +67,46 @@ class ReleaseAlertState( val isNewSeasonRelease: Boolean, ) -private const val ReleaseAlertWindowMs = 60L * 24 * 60 * 60 * 1000 -private val NoReleaseAlertState = ReleaseAlertState(false, false) - fun calculateReleaseAlertState( seedLastUpdatedEpochMs: Long, seedSeasonNumber: Int?, nextSeasonNumber: Int?, releasedIso: String?, ): ReleaseAlertState { - if (releasedIso.isNullOrBlank()) return NoReleaseAlertState - val releaseEpoch = parseReleaseDateToEpochMs(releasedIso) - ?: return NoReleaseAlertState - val nowMs = WatchProgressClock.nowEpochMs() - if (nowMs < releaseEpoch) return NoReleaseAlertState - if (releaseEpoch <= seedLastUpdatedEpochMs) return NoReleaseAlertState - if (nowMs - releaseEpoch >= ReleaseAlertWindowMs) return NoReleaseAlertState - val isNewSeasonRelease = + val log = Logger.withTag("ReleaseAlert") + log.d { + "calculateReleaseAlertState inputs: releasedIso=$releasedIso, " + + "releaseEpoch=$releaseEpoch, seedLastUpdatedEpochMs=$seedLastUpdatedEpochMs, " + + "seedSeasonNumber=$seedSeasonNumber, nextSeasonNumber=$nextSeasonNumber, nowMs=$nowMs" + } + + if (releaseEpoch == null) { + log.d { "calculateReleaseAlertState failed: releaseEpoch is null" } + return ReleaseAlertState(false, false) + } + + val hasAired = nowMs >= releaseEpoch + val sixtyDaysMs = 60L * 24 * 60 * 60 * 1000 + val isReleaseAlert = hasAired && + releaseEpoch > seedLastUpdatedEpochMs && + (nowMs - releaseEpoch) < sixtyDaysMs + + val isNewSeasonRelease = isReleaseAlert && seedSeasonNumber != null && nextSeasonNumber != null && nextSeasonNumber != seedSeasonNumber + log.d { + "calculateReleaseAlertState result: isReleaseAlert=$isReleaseAlert (hasAired=$hasAired, " + + "epoch>seed=${releaseEpoch > seedLastUpdatedEpochMs}, ageMs=${nowMs - releaseEpoch}), " + + "isNewSeasonRelease=$isNewSeasonRelease" + } + return ReleaseAlertState( - isReleaseAlert = true, + isReleaseAlert = isReleaseAlert, isNewSeasonRelease = isNewSeasonRelease ) } diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/Main.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/Main.kt deleted file mode 100644 index 04cded01..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/Main.kt +++ /dev/null @@ -1,94 +0,0 @@ -package com.nuvio.app - -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.window.Window -import androidx.compose.ui.window.WindowPlacement -import androidx.compose.ui.window.application -import androidx.compose.ui.window.rememberWindowState -import androidx.compose.ui.unit.dp -import com.nuvio.app.features.player.PlatformPlayerSurface -import com.nuvio.app.features.player.desktop.applyNativeDesktopWindowChrome -import com.nuvio.app.features.player.desktop.installDesktopAppFullscreenShortcuts -import com.nuvio.app.features.player.desktop.preloadNativePlayerBridgeAsync -import com.nuvio.app.features.player.desktop.registerDesktopAppFullscreenToggle -import java.awt.Color as AwtColor -import javax.swing.JComponent - -private val NuvioDesktopNativeBackground = AwtColor(0x0D, 0x0D, 0x0D) -private const val NuvioDesktopIconPath = "icons/nuvio-app-icon.png" -private const val MacosDarkAquaAppearance = "NSAppearanceNameDarkAqua" - -fun main() { - configureDesktopChrome() - preloadNativePlayerBridgeAsync() - - application { - val smokePlayerUrl = ( - System.getProperty("nuvio.desktop.smokePlayerUrl") - ?: System.getenv("NUVIO_DESKTOP_SMOKE_PLAYER_URL") - ) - ?.takeIf { it.isNotBlank() } - val windowState = rememberWindowState(width = 1280.dp, height = 820.dp) - val restoreWindowPlacement = remember { mutableStateOf(WindowPlacement.Floating) } - - Window( - onCloseRequest = ::exitApplication, - title = if (smokePlayerUrl == null) "Nuvio" else "Nuvio Player Smoke", - state = windowState, - icon = painterResource(NuvioDesktopIconPath), - ) { - SideEffect { - window.background = NuvioDesktopNativeBackground - window.rootPane.background = NuvioDesktopNativeBackground - window.contentPane.background = NuvioDesktopNativeBackground - (window.contentPane as? JComponent)?.isOpaque = true - } - LaunchedEffect(window) { - applyNativeDesktopWindowChrome(window) - } - DisposableEffect(window, windowState) { - val unregisterFullscreenToggle = registerDesktopAppFullscreenToggle { targetWindow -> - if (targetWindow != null && targetWindow !== window) return@registerDesktopAppFullscreenToggle - if (windowState.placement == WindowPlacement.Fullscreen) { - windowState.placement = restoreWindowPlacement.value - } else { - restoreWindowPlacement.value = windowState.placement - .takeUnless { it == WindowPlacement.Fullscreen } - ?: WindowPlacement.Floating - windowState.placement = WindowPlacement.Fullscreen - } - } - val uninstallFullscreenShortcuts = installDesktopAppFullscreenShortcuts(window) - onDispose { - uninstallFullscreenShortcuts() - unregisterFullscreenToggle() - } - } - - if (smokePlayerUrl == null) { - App() - } else { - PlatformPlayerSurface( - sourceUrl = smokePlayerUrl, - modifier = Modifier.fillMaxSize(), - onControllerReady = {}, - onSnapshot = {}, - onError = {}, - ) - } - } - } -} - -private fun configureDesktopChrome() { - if (System.getProperty("os.name").contains("mac", ignoreCase = true)) { - System.setProperty("apple.awt.application.appearance", MacosDarkAquaAppearance) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/Platform.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/Platform.desktop.kt deleted file mode 100644 index 79fd9ee7..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/Platform.desktop.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.nuvio.app - -class DesktopPlatform : Platform { - override val name: String = "Desktop ${System.getProperty("os.name").orEmpty()}".trim() -} - -actual fun getPlatform(): Platform = DesktopPlatform() - -internal actual val isIos: Boolean = false -internal actual val isDesktop: Boolean = true diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/auth/AuthStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/auth/AuthStorage.desktop.kt deleted file mode 100644 index c0dc1da4..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/auth/AuthStorage.desktop.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.nuvio.app.core.auth - -import com.nuvio.app.core.storage.DesktopStorage - -internal actual object AuthStorage { - private val store = DesktopStorage.store("nuvio_auth") - - actual fun loadAnonymousUserId(): String? = - store.getString("anonymous_user_id") - - actual fun saveAnonymousUserId(userId: String) { - store.putString("anonymous_user_id", userId) - } - - actual fun clearAnonymousUserId() { - store.remove("anonymous_user_id") - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt index a989c77d..2dda9682 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt @@ -1,9 +1,7 @@ package com.nuvio.app.core.build actual object AppFeaturePolicy { - actual val pluginsEnabled: Boolean = true - actual val downloadsEnabled: Boolean = false - actual val notificationsEnabled: Boolean = false + actual val pluginsEnabled: Boolean = false actual val p2pEnabled: Boolean = false actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.EXTERNAL actual val heroTrailerPlaybackSupported: Boolean = false diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppVersionPolicy.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppVersionPolicy.desktop.kt deleted file mode 100644 index 88ada633..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppVersionPolicy.desktop.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.nuvio.app.core.build - -actual object AppVersionPolicy { - actual val displayVersionName: String = AppVersionConfig.DESKTOP_VERSION_NAME - actual val displayVersionCode: Int = AppVersionConfig.DESKTOP_VERSION_CODE - actual val basedOnVersionName: String? = AppVersionConfig.VERSION_NAME.takeIf { it != displayVersionName } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/storage/DesktopStorage.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/storage/DesktopStorage.kt deleted file mode 100644 index ea0a4eeb..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/storage/DesktopStorage.kt +++ /dev/null @@ -1,148 +0,0 @@ -package com.nuvio.app.core.storage - -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.Paths -import java.util.Comparator -import java.util.Locale -import java.util.Properties -import kotlin.io.path.exists - -internal object DesktopStorage { - private val json = Json { ignoreUnknownKeys = true } - private val stores = mutableMapOf() - - val rootDir: Path by lazy { - resolveAppDataDir().also { Files.createDirectories(it) } - } - - fun store(name: String): Store = synchronized(stores) { - stores.getOrPut(name) { Store(rootDir.resolve("$name.properties")) } - } - - fun wipe() { - synchronized(stores) { - stores.values.forEach(Store::clearInMemory) - stores.clear() - } - if (!rootDir.exists()) return - Files.walk(rootDir).use { stream -> - stream - .sorted(Comparator.reverseOrder()) - .filter { it != rootDir } - .forEach { path -> runCatching { Files.deleteIfExists(path) } } - } - } - - private fun resolveAppDataDir(): Path { - val osName = System.getProperty("os.name").orEmpty().lowercase(Locale.ROOT) - val userHome = Paths.get(System.getProperty("user.home").orEmpty()) - return when { - osName.contains("mac") -> userHome.resolve("Library/Application Support/Nuvio") - osName.contains("win") -> { - val appData = System.getenv("APPDATA")?.takeIf { it.isNotBlank() } - (appData?.let(Paths::get) ?: userHome.resolve("AppData/Roaming")).resolve("Nuvio") - } - else -> { - val xdgConfig = System.getenv("XDG_CONFIG_HOME")?.takeIf { it.isNotBlank() } - (xdgConfig?.let(Paths::get) ?: userHome.resolve(".config")).resolve("nuvio") - } - } - } - - internal class Store( - private val file: Path, - ) { - private val lock = Any() - private val properties = Properties() - private var loaded = false - - fun contains(key: String): Boolean = synchronized(lock) { - ensureLoaded() - properties.containsKey(key) - } - - fun getString(key: String): String? = synchronized(lock) { - ensureLoaded() - properties.getProperty(key) - } - - fun putString(key: String, value: String?) = synchronized(lock) { - ensureLoaded() - if (value == null) { - properties.remove(key) - } else { - properties.setProperty(key, value) - } - persist() - } - - fun getBoolean(key: String): Boolean? = - getString(key)?.toBooleanStrictOrNull() - - fun putBoolean(key: String, value: Boolean) { - putString(key, value.toString()) - } - - fun getInt(key: String): Int? = - getString(key)?.toIntOrNull() - - fun putInt(key: String, value: Int) { - putString(key, value.toString()) - } - - fun getFloat(key: String): Float? = - getString(key)?.toFloatOrNull() - - fun putFloat(key: String, value: Float) { - putString(key, value.toString()) - } - - fun getStringSet(key: String): Set? = - getString(key)?.let { payload -> - runCatching { json.decodeFromString>(payload).toSet() }.getOrNull() - } - - fun putStringSet(key: String, values: Set) { - putString(key, json.encodeToString(values.toList())) - } - - fun remove(key: String) = synchronized(lock) { - ensureLoaded() - properties.remove(key) - persist() - } - - fun removeAll(keys: Iterable) = synchronized(lock) { - ensureLoaded() - keys.forEach(properties::remove) - persist() - } - - fun clearInMemory() = synchronized(lock) { - properties.clear() - loaded = false - } - - private fun ensureLoaded() { - if (loaded) return - loaded = true - properties.clear() - if (!file.exists()) return - runCatching { - Files.newInputStream(file).use { input -> - properties.load(input) - } - } - } - - private fun persist() { - Files.createDirectories(file.parent) - Files.newOutputStream(file).use { output -> - properties.store(output, "Nuvio desktop preferences") - } - } - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.desktop.kt deleted file mode 100644 index b7b0305d..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.desktop.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.nuvio.app.core.storage - -internal actual object PlatformLocalAccountDataCleaner { - actual fun wipe() { - DesktopStorage.wipe() - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/sync/AppForegroundMonitor.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/sync/AppForegroundMonitor.desktop.kt deleted file mode 100644 index e0c469fc..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/sync/AppForegroundMonitor.desktop.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.nuvio.app.core.sync - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.emptyFlow - -internal actual object AppForegroundMonitor { - actual fun events(): Flow = emptyFlow() -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/AppIconPainter.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/AppIconPainter.desktop.kt deleted file mode 100644 index 86ad57cd..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/AppIconPainter.desktop.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.nuvio.app.core.ui - -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.painter.Painter -import nuvio.composeapp.generated.resources.Res -import nuvio.composeapp.generated.resources.ic_player_aspect_ratio -import nuvio.composeapp.generated.resources.ic_player_audio_filled -import nuvio.composeapp.generated.resources.ic_player_pause -import nuvio.composeapp.generated.resources.ic_player_play -import nuvio.composeapp.generated.resources.ic_player_subtitles -import nuvio.composeapp.generated.resources.library_add_plus -import org.jetbrains.compose.resources.painterResource - -@Composable -actual fun appIconPainter(icon: AppIconResource): Painter = - painterResource( - when (icon) { - AppIconResource.PlayerPlay -> Res.drawable.ic_player_play - AppIconResource.PlayerPause -> Res.drawable.ic_player_pause - AppIconResource.PlayerAspectRatio -> Res.drawable.ic_player_aspect_ratio - AppIconResource.PlayerSubtitles -> Res.drawable.ic_player_subtitles - AppIconResource.PlayerAudioFilled -> Res.drawable.ic_player_audio_filled - AppIconResource.LibraryAddPlus -> Res.drawable.library_add_plus - }, - ) diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.desktop.kt deleted file mode 100644 index c7c556c5..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.desktop.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.nuvio.app.core.ui - -internal actual fun isLiquidGlassNativeTabBarSupported(): Boolean = false - -internal actual fun publishLiquidGlassNativeTabBarEnabled(enabled: Boolean) = Unit - -internal actual fun publishNativeTabBarVisible(visible: Boolean) = Unit - -internal actual fun publishNativeSelectedTab(tabName: String) = Unit - -internal actual fun publishNativeTabAccentColor(hexColor: String) = Unit - -internal actual fun publishNativeProfileTabIcon( - name: String?, - avatarColorHex: String?, - avatarImageUrl: String?, - avatarBackgroundColorHex: String?, -) = Unit diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.desktop.kt deleted file mode 100644 index 24d3d73d..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.desktop.kt +++ /dev/null @@ -1,281 +0,0 @@ -package com.nuvio.app.core.ui - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.DefaultAlpha -import androidx.compose.ui.graphics.FilterQuality -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.asComposeImageBitmap -import androidx.compose.ui.graphics.asSkiaBitmap -import androidx.compose.ui.graphics.drawscope.DrawScope -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntSize -import coil3.BitmapImage -import coil3.Image -import coil3.PlatformContext -import coil3.compose.AsyncImage -import coil3.compose.AsyncImagePainter -import coil3.compose.LocalPlatformContext -import coil3.request.ImageRequest -import coil3.request.NullRequestDataException -import org.jetbrains.skia.Bitmap -import org.jetbrains.skia.FilterMipmap -import org.jetbrains.skia.FilterMode -import org.jetbrains.skia.Image as SkiaImage -import org.jetbrains.skia.MipmapMode -import kotlin.math.max -import kotlin.math.roundToInt - -private const val MinCustomDownscaleRatio = 1.08f -private const val MaxDesktopSourceSizePx = 1536 -private const val MaxScaledBitmapPixels = 1_250_000L - -private val IsWindowsDesktop: Boolean = - System.getProperty("os.name") - ?.startsWith("Windows", ignoreCase = true) - ?: false - -@Composable -internal actual fun NuvioAsyncImage( - model: Any?, - contentDescription: String?, - modifier: Modifier, - placeholder: Painter?, - error: Painter?, - fallback: Painter?, - onLoading: ((AsyncImagePainter.State.Loading) -> Unit)?, - onSuccess: ((AsyncImagePainter.State.Success) -> Unit)?, - onError: ((AsyncImagePainter.State.Error) -> Unit)?, - alignment: Alignment, - contentScale: ContentScale, - alpha: Float, - colorFilter: ColorFilter?, - filterQuality: FilterQuality?, - clipToBounds: Boolean, - desktopImageScaling: NuvioDesktopImageScaling, -) { - val context = LocalPlatformContext.current - val effectiveDesktopImageScaling = remember(desktopImageScaling) { - if (IsWindowsDesktop) desktopImageScaling else NuvioDesktopImageScaling.Disabled - } - val requestModel = remember(context, model, effectiveDesktopImageScaling) { - if (effectiveDesktopImageScaling == NuvioDesktopImageScaling.Disabled) { - model - } else { - model.withDesktopHighQualitySize(context) - } - } - val transform: (AsyncImagePainter.State) -> AsyncImagePainter.State = remember( - placeholder, - error, - fallback, - effectiveDesktopImageScaling, - ) { - { state -> - when (state) { - is AsyncImagePainter.State.Loading -> { - placeholder?.let { state.copy(painter = it) } ?: state - } - is AsyncImagePainter.State.Success -> { - state.result.image.toScaledBitmapPainter(effectiveDesktopImageScaling) - ?.let { state.copy(painter = it) } - ?: state - } - is AsyncImagePainter.State.Error -> { - val fallbackPainter = if (state.result.throwable is NullRequestDataException) { - fallback ?: error - } else { - error - } - fallbackPainter?.let { state.copy(painter = it) } ?: state - } - AsyncImagePainter.State.Empty -> state - } - } - } - val onState: ((AsyncImagePainter.State) -> Unit)? = remember(onLoading, onSuccess, onError) { - if (onLoading == null && onSuccess == null && onError == null) { - null - } else { - { state -> - when (state) { - is AsyncImagePainter.State.Loading -> onLoading?.invoke(state) - is AsyncImagePainter.State.Success -> onSuccess?.invoke(state) - is AsyncImagePainter.State.Error -> onError?.invoke(state) - AsyncImagePainter.State.Empty -> Unit - } - } - } - } - - AsyncImage( - model = requestModel, - contentDescription = contentDescription, - modifier = modifier, - transform = transform, - onState = onState, - alignment = alignment, - contentScale = contentScale, - alpha = alpha, - colorFilter = colorFilter, - filterQuality = filterQuality ?: FilterQuality.High, - clipToBounds = clipToBounds, - ) -} - -private fun Any?.withDesktopHighQualitySize(context: PlatformContext): Any? { - if (this == null) return null - - return if (this is ImageRequest) { - newBuilder() - .size(MaxDesktopSourceSizePx) - .build() - } else { - ImageRequest.Builder(context) - .data(this) - .size(MaxDesktopSourceSizePx) - .build() - } -} - -private fun Image.toScaledBitmapPainter(desktopImageScaling: NuvioDesktopImageScaling): Painter? { - if (desktopImageScaling == NuvioDesktopImageScaling.Disabled) return null - - return (this as? BitmapImage) - ?.bitmap - ?.asComposeImageBitmap() - ?.let { imageBitmap -> ScaledBitmapPainter(imageBitmap) } -} - -private class ScaledBitmapPainter( - private val image: ImageBitmap, -) : Painter() { - private var cachedSize: IntSize? = null - private var cachedBitmap: ImageBitmap? = null - private var alpha: Float = DefaultAlpha - private var colorFilter: ColorFilter? = null - - override val intrinsicSize: Size = - Size(image.width.toFloat(), image.height.toFloat()) - - override fun DrawScope.onDraw() { - val drawSize = IntSize( - width = size.width.roundToInt().coerceAtLeast(1), - height = size.height.roundToInt().coerceAtLeast(1), - ) - if (!shouldUseScaledBitmap(drawSize)) { - drawSource(drawSize) - return - } - - val cacheSize = drawSize.cacheSize() - if (cacheSize.pixelCount() > MaxScaledBitmapPixels) { - drawSource(drawSize) - return - } - - val bitmap = scaledBitmap(cacheSize) - - drawImage( - image = bitmap, - srcOffset = IntOffset.Zero, - srcSize = cacheSize, - dstOffset = IntOffset.Zero, - dstSize = drawSize, - alpha = alpha, - colorFilter = colorFilter, - filterQuality = if (cacheSize == drawSize) FilterQuality.None else FilterQuality.Medium, - ) - } - - override fun applyAlpha(alpha: Float): Boolean { - this.alpha = alpha - return true - } - - override fun applyColorFilter(colorFilter: ColorFilter?): Boolean { - this.colorFilter = colorFilter - return true - } - - private fun scaledBitmap(size: IntSize): ImageBitmap { - cachedBitmap?.let { bitmap -> - if (cachedSize == size) return bitmap - } - return image.scale(size.width, size.height).also { bitmap -> - cachedSize = size - cachedBitmap = bitmap - } - } - - private fun DrawScope.drawSource(drawSize: IntSize) { - drawImage( - image = image, - srcOffset = IntOffset.Zero, - srcSize = IntSize(image.width, image.height), - dstOffset = IntOffset.Zero, - dstSize = drawSize, - alpha = alpha, - colorFilter = colorFilter, - filterQuality = FilterQuality.High, - ) - } - - private fun shouldUseScaledBitmap(drawSize: IntSize): Boolean { - if (drawSize.pixelCount() > MaxScaledBitmapPixels) return false - - val widthScale = image.width.toFloat() / drawSize.width.toFloat() - val heightScale = image.height.toFloat() / drawSize.height.toFloat() - return max(widthScale, heightScale) >= MinCustomDownscaleRatio - } - - private fun IntSize.cacheSize(): IntSize { - val quantum = cacheQuantum() - return IntSize( - width = width.roundUpTo(quantum), - height = height.roundUpTo(quantum), - ) - } - - private fun IntSize.cacheQuantum(): Int { - val longestSide = max(width, height) - return when { - longestSide >= 1200 -> 16 - longestSide >= 600 -> 8 - longestSide >= 200 -> 4 - else -> 2 - } - } - - private fun Int.roundUpTo(quantum: Int): Int = - ((this + quantum - 1) / quantum) * quantum - - private fun IntSize.pixelCount(): Long = - width.toLong() * height.toLong() - - private fun ImageBitmap.scale(width: Int, height: Int): ImageBitmap { - val image = SkiaImage.makeFromBitmap(asSkiaBitmap()) - return try { - image.scale(width, height) - } finally { - image.close() - } - } - - private fun SkiaImage.scale(width: Int, height: Int): ImageBitmap { - val bitmap = Bitmap() - bitmap.allocN32Pixels(width, height) - scalePixels( - bitmap.peekPixels()!!, - FilterMipmap(FilterMode.LINEAR, MipmapMode.LINEAR), - false, - ) - return bitmap.asComposeImageBitmap() - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.desktop.kt deleted file mode 100644 index 5fdd5960..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.desktop.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.nuvio.app.core.ui - -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.runtime.Composable -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp - -internal actual val nuvioPlatformExtraTopPadding: Dp = 0.dp -internal actual val nuvioPlatformExtraBottomPadding: Dp = 0.dp -internal actual val nuvioBottomNavigationExtraVerticalPadding: Dp = 0.dp - -@Composable -internal actual fun nuvioBottomNavigationBarInsets(): WindowInsets = WindowInsets(0.dp) diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformBackHandler.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformBackHandler.desktop.kt deleted file mode 100644 index 6434bd49..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformBackHandler.desktop.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.nuvio.app.core.ui - -import androidx.compose.runtime.Composable - -@Composable -actual fun PlatformBackHandler( - enabled: Boolean, - onBack: () -> Unit, -) = Unit diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformExitApp.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformExitApp.desktop.kt deleted file mode 100644 index d96114b5..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformExitApp.desktop.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.nuvio.app.core.ui - -import kotlin.system.exitProcess - -actual fun platformExitApp() { - exitProcess(0) -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformImageLoader.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformImageLoader.desktop.kt deleted file mode 100644 index 6734e5fd..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformImageLoader.desktop.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.nuvio.app.core.ui - -import coil3.ImageLoader - -internal actual fun ImageLoader.Builder.configurePlatformImageLoader(): ImageLoader.Builder = this diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleStorage.desktop.kt deleted file mode 100644 index 687ee798..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleStorage.desktop.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.nuvio.app.core.ui - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey - -internal actual object PosterCardStyleStorage { - private val store = DesktopStorage.store("nuvio_poster_card_style") - - actual fun loadPayload(): String? = - store.getString(ProfileScopedKey.of("poster_card_style")) - - actual fun savePayload(payload: String) { - store.putString(ProfileScopedKey.of("poster_card_style"), payload) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/SecondaryClick.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/SecondaryClick.desktop.kt deleted file mode 100644 index 4b20c306..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/SecondaryClick.desktop.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.nuvio.app.core.ui - -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.pointer.PointerButton -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.pointerInput - -@OptIn(ExperimentalComposeUiApi::class) -internal actual fun Modifier.secondaryClick(onClick: (() -> Unit)?): Modifier { - if (onClick == null) return this - - return pointerInput(onClick) { - awaitPointerEventScope { - while (true) { - val event = awaitPointerEvent() - if (event.type == PointerEventType.Press && event.button == PointerButton.Secondary) { - event.changes.forEach { change -> change.consume() } - onClick() - } - } - } - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.desktop.kt deleted file mode 100644 index bff3bf30..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.desktop.kt +++ /dev/null @@ -1,108 +0,0 @@ -package com.nuvio.app.features.addons - -import com.nuvio.app.core.storage.DesktopStorage -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import java.net.URI -import java.net.http.HttpClient -import java.net.http.HttpRequest -import java.net.http.HttpResponse -import java.time.Duration - -internal actual object AddonStorage { - private val store = DesktopStorage.store("nuvio_addons") - private val json = Json { ignoreUnknownKeys = true } - - actual fun loadInstalledAddonUrls(profileId: Int): List = - store.getString("installed_addon_urls_$profileId") - ?.let { payload -> runCatching { json.decodeFromString>(payload) }.getOrNull() } - ?: emptyList() - - actual fun saveInstalledAddonUrls(profileId: Int, urls: List) { - store.putString("installed_addon_urls_$profileId", json.encodeToString(urls)) - } - - actual fun loadAddonEnabledStates(profileId: Int): Map = - store.getString("addon_enabled_states_$profileId") - ?.let { payload -> runCatching { json.decodeFromString>(payload) }.getOrNull() } - ?: emptyMap() - - actual fun saveAddonEnabledStates(profileId: Int, states: Map) { - store.putString("addon_enabled_states_$profileId", json.encodeToString(states)) - } -} - -private val desktopHttpClient: HttpClient = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(30)) - .followRedirects(HttpClient.Redirect.NORMAL) - .build() - -actual suspend fun httpGetText(url: String): String = - httpGetTextWithHeaders(url, emptyMap()) - -actual suspend fun httpPostJson(url: String, body: String): String = - httpPostJsonWithHeaders(url, body, emptyMap()) - -actual suspend fun httpGetTextWithHeaders( - url: String, - headers: Map, -): String = - httpRequestRaw("GET", url, headers, body = "").body - -actual suspend fun httpPostJsonWithHeaders( - url: String, - body: String, - headers: Map, -): String = - httpRequestRaw( - method = "POST", - url = url, - headers = mapOf("Content-Type" to "application/json") + headers, - body = body, - ).body - -actual suspend fun httpRequestRaw( - method: String, - url: String, - headers: Map, - body: String, - followRedirects: Boolean, -): RawHttpResponse = withContext(Dispatchers.IO) { - val client = if (followRedirects) { - desktopHttpClient - } else { - HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(30)) - .followRedirects(HttpClient.Redirect.NEVER) - .build() - } - val normalizedMethod = method.trim().uppercase().ifBlank { "GET" } - val requestBuilder = HttpRequest.newBuilder() - .uri(URI(url)) - .timeout(Duration.ofSeconds(60)) - .method( - normalizedMethod, - if (normalizedMethod == "GET" || normalizedMethod == "HEAD") { - HttpRequest.BodyPublishers.noBody() - } else { - HttpRequest.BodyPublishers.ofString(body) - }, - ) - - headers.forEach { (key, value) -> - if (key.isNotBlank() && value.isNotBlank()) { - requestBuilder.header(key, value) - } - } - - val response = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()) - RawHttpResponse( - status = response.statusCode(), - statusText = "HTTP ${response.statusCode()}", - url = response.uri().toString(), - body = response.body(), - headers = response.headers().map().mapValues { (_, values) -> values.joinToString(",") }, - ) -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.desktop.kt deleted file mode 100644 index 8598fa62..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.desktop.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.nuvio.app.features.collection - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey - -internal actual object CollectionMobileSettingsStorage { - private val store = DesktopStorage.store("nuvio_collection_mobile_settings") - - actual fun loadPayload(): String? = - store.getString(ProfileScopedKey.of("collection_mobile_settings")) - - actual fun savePayload(payload: String) { - store.putString(ProfileScopedKey.of("collection_mobile_settings"), payload) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/collection/CollectionStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/collection/CollectionStorage.desktop.kt deleted file mode 100644 index 3bd8bb6c..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/collection/CollectionStorage.desktop.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.nuvio.app.features.collection - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey - -internal actual object CollectionStorage { - private val store = DesktopStorage.store("nuvio_collections") - - actual fun loadPayload(): String? = - store.getString(ProfileScopedKey.of("collections")) - - actual fun savePayload(payload: String) { - store.putString(ProfileScopedKey.of("collections"), payload) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.desktop.kt deleted file mode 100644 index d47c69b4..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.desktop.kt +++ /dev/null @@ -1,138 +0,0 @@ -package com.nuvio.app.features.debrid - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey -import com.nuvio.app.core.sync.decodeSyncBoolean -import com.nuvio.app.core.sync.decodeSyncInt -import com.nuvio.app.core.sync.decodeSyncString -import com.nuvio.app.core.sync.encodeSyncBoolean -import com.nuvio.app.core.sync.encodeSyncInt -import com.nuvio.app.core.sync.encodeSyncString -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put - -internal actual object DebridSettingsStorage { - private const val enabledKey = "debrid_enabled" - private const val cloudLibraryEnabledKey = "debrid_cloud_library_enabled" - private const val preferredResolverProviderIdKey = "debrid_preferred_resolver_provider_id" - private const val torboxApiKeyKey = "debrid_torbox_api_key" - private const val realDebridApiKeyKey = "debrid_real_debrid_api_key" - private const val instantPlaybackPreparationLimitKey = "debrid_instant_playback_preparation_limit" - private const val streamMaxResultsKey = "debrid_stream_max_results" - private const val streamSortModeKey = "debrid_stream_sort_mode" - private const val streamMinimumQualityKey = "debrid_stream_minimum_quality" - private const val streamDolbyVisionFilterKey = "debrid_stream_dolby_vision_filter" - private const val streamHdrFilterKey = "debrid_stream_hdr_filter" - private const val streamCodecFilterKey = "debrid_stream_codec_filter" - private const val streamPreferencesKey = "debrid_stream_preferences" - private const val streamNameTemplateKey = "debrid_stream_name_template" - private const val streamDescriptionTemplateKey = "debrid_stream_description_template" - private val store = DesktopStorage.store("nuvio_debrid_settings") - - actual fun loadEnabled(): Boolean? = loadBoolean(enabledKey) - actual fun saveEnabled(enabled: Boolean) = saveBoolean(enabledKey, enabled) - actual fun loadCloudLibraryEnabled(): Boolean? = loadBoolean(cloudLibraryEnabledKey) - actual fun saveCloudLibraryEnabled(enabled: Boolean) = saveBoolean(cloudLibraryEnabledKey, enabled) - actual fun loadPreferredResolverProviderId(): String? = loadString(preferredResolverProviderIdKey) - actual fun savePreferredResolverProviderId(providerId: String) = saveString(preferredResolverProviderIdKey, providerId) - actual fun loadProviderApiKey(providerId: String): String? = loadString(providerApiKeyKey(providerId)) - actual fun saveProviderApiKey(providerId: String, apiKey: String) = saveString(providerApiKeyKey(providerId), apiKey) - actual fun loadTorboxApiKey(): String? = loadProviderApiKey(DebridProviders.TORBOX_ID) - actual fun saveTorboxApiKey(apiKey: String) = saveProviderApiKey(DebridProviders.TORBOX_ID, apiKey) - actual fun loadRealDebridApiKey(): String? = loadProviderApiKey(DebridProviders.REAL_DEBRID_ID) - actual fun saveRealDebridApiKey(apiKey: String) = saveProviderApiKey(DebridProviders.REAL_DEBRID_ID, apiKey) - actual fun loadInstantPlaybackPreparationLimit(): Int? = loadInt(instantPlaybackPreparationLimitKey) - actual fun saveInstantPlaybackPreparationLimit(limit: Int) = saveInt(instantPlaybackPreparationLimitKey, limit) - actual fun loadStreamMaxResults(): Int? = loadInt(streamMaxResultsKey) - actual fun saveStreamMaxResults(maxResults: Int) = saveInt(streamMaxResultsKey, maxResults) - actual fun loadStreamSortMode(): String? = loadString(streamSortModeKey) - actual fun saveStreamSortMode(mode: String) = saveString(streamSortModeKey, mode) - actual fun loadStreamMinimumQuality(): String? = loadString(streamMinimumQualityKey) - actual fun saveStreamMinimumQuality(quality: String) = saveString(streamMinimumQualityKey, quality) - actual fun loadStreamDolbyVisionFilter(): String? = loadString(streamDolbyVisionFilterKey) - actual fun saveStreamDolbyVisionFilter(filter: String) = saveString(streamDolbyVisionFilterKey, filter) - actual fun loadStreamHdrFilter(): String? = loadString(streamHdrFilterKey) - actual fun saveStreamHdrFilter(filter: String) = saveString(streamHdrFilterKey, filter) - actual fun loadStreamCodecFilter(): String? = loadString(streamCodecFilterKey) - actual fun saveStreamCodecFilter(filter: String) = saveString(streamCodecFilterKey, filter) - actual fun loadStreamPreferences(): String? = loadString(streamPreferencesKey) - actual fun saveStreamPreferences(preferences: String) = saveString(streamPreferencesKey, preferences) - actual fun loadStreamNameTemplate(): String? = loadString(streamNameTemplateKey) - actual fun saveStreamNameTemplate(template: String) = saveString(streamNameTemplateKey, template) - actual fun loadStreamDescriptionTemplate(): String? = loadString(streamDescriptionTemplateKey) - actual fun saveStreamDescriptionTemplate(template: String) = saveString(streamDescriptionTemplateKey, template) - - private fun loadBoolean(key: String): Boolean? = store.getBoolean(ProfileScopedKey.of(key)) - private fun saveBoolean(key: String, value: Boolean) = store.putBoolean(ProfileScopedKey.of(key), value) - private fun loadInt(key: String): Int? = store.getInt(ProfileScopedKey.of(key)) - private fun saveInt(key: String, value: Int) = store.putInt(ProfileScopedKey.of(key), value) - private fun loadString(key: String): String? = store.getString(ProfileScopedKey.of(key)) - private fun saveString(key: String, value: String) = store.putString(ProfileScopedKey.of(key), value) - - actual fun exportToSyncPayload(): JsonObject = buildJsonObject { - loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) } - loadCloudLibraryEnabled()?.let { put(cloudLibraryEnabledKey, encodeSyncBoolean(it)) } - loadPreferredResolverProviderId()?.let { put(preferredResolverProviderIdKey, encodeSyncString(it)) } - DebridProviders.all().forEach { provider -> - loadProviderApiKey(provider.id)?.let { put(providerApiKeyKey(provider.id), encodeSyncString(it)) } - } - loadInstantPlaybackPreparationLimit()?.let { put(instantPlaybackPreparationLimitKey, encodeSyncInt(it)) } - loadStreamMaxResults()?.let { put(streamMaxResultsKey, encodeSyncInt(it)) } - loadStreamSortMode()?.let { put(streamSortModeKey, encodeSyncString(it)) } - loadStreamMinimumQuality()?.let { put(streamMinimumQualityKey, encodeSyncString(it)) } - loadStreamDolbyVisionFilter()?.let { put(streamDolbyVisionFilterKey, encodeSyncString(it)) } - loadStreamHdrFilter()?.let { put(streamHdrFilterKey, encodeSyncString(it)) } - loadStreamCodecFilter()?.let { put(streamCodecFilterKey, encodeSyncString(it)) } - loadStreamPreferences()?.let { put(streamPreferencesKey, encodeSyncString(it)) } - loadStreamNameTemplate()?.let { put(streamNameTemplateKey, encodeSyncString(it)) } - loadStreamDescriptionTemplate()?.let { put(streamDescriptionTemplateKey, encodeSyncString(it)) } - } - - actual fun replaceFromSyncPayload(payload: JsonObject) { - store.removeAll(syncKeys().map(ProfileScopedKey::of)) - payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled) - payload.decodeSyncBoolean(cloudLibraryEnabledKey)?.let(::saveCloudLibraryEnabled) - payload.decodeSyncString(preferredResolverProviderIdKey)?.let(::savePreferredResolverProviderId) - DebridProviders.all().forEach { provider -> - payload.decodeSyncString(providerApiKeyKey(provider.id))?.let { saveProviderApiKey(provider.id, it) } - } - payload.decodeSyncInt(instantPlaybackPreparationLimitKey)?.let(::saveInstantPlaybackPreparationLimit) - payload.decodeSyncInt(streamMaxResultsKey)?.let(::saveStreamMaxResults) - payload.decodeSyncString(streamSortModeKey)?.let(::saveStreamSortMode) - payload.decodeSyncString(streamMinimumQualityKey)?.let(::saveStreamMinimumQuality) - payload.decodeSyncString(streamDolbyVisionFilterKey)?.let(::saveStreamDolbyVisionFilter) - payload.decodeSyncString(streamHdrFilterKey)?.let(::saveStreamHdrFilter) - payload.decodeSyncString(streamCodecFilterKey)?.let(::saveStreamCodecFilter) - payload.decodeSyncString(streamPreferencesKey)?.let(::saveStreamPreferences) - payload.decodeSyncString(streamNameTemplateKey)?.let(::saveStreamNameTemplate) - payload.decodeSyncString(streamDescriptionTemplateKey)?.let(::saveStreamDescriptionTemplate) - } - - private fun syncKeys(): List = - listOf( - enabledKey, - cloudLibraryEnabledKey, - preferredResolverProviderIdKey, - instantPlaybackPreparationLimitKey, - streamMaxResultsKey, - streamSortModeKey, - streamMinimumQualityKey, - streamDolbyVisionFilterKey, - streamHdrFilterKey, - streamCodecFilterKey, - streamPreferencesKey, - streamNameTemplateKey, - streamDescriptionTemplateKey, - ) + DebridProviders.all().map { providerApiKeyKey(it.id) } - - private fun providerApiKeyKey(providerId: String): String { - val normalized = DebridProviders.byId(providerId)?.id - ?: providerId.trim().lowercase().replace(Regex("[^a-z0-9_]+"), "_") - return when (normalized) { - DebridProviders.TORBOX_ID -> torboxApiKeyKey - DebridProviders.REAL_DEBRID_ID -> realDebridApiKeyKey - else -> "debrid_${normalized}_api_key" - } - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsStorage.desktop.kt deleted file mode 100644 index 5112956a..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsStorage.desktop.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.nuvio.app.features.details - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey - -internal actual object MetaScreenSettingsStorage { - private val store = DesktopStorage.store("nuvio_meta_screen_settings") - - actual fun loadPayload(): String? = - store.getString(ProfileScopedKey.of("meta_screen_settings")) - - actual fun savePayload(payload: String) { - store.putString(ProfileScopedKey.of("meta_screen_settings"), payload) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/SeasonViewModeStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/SeasonViewModeStorage.desktop.kt deleted file mode 100644 index 2c961b32..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/SeasonViewModeStorage.desktop.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.nuvio.app.features.details - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey - -internal actual object SeasonViewModeStorage { - private val store = DesktopStorage.store("nuvio_season_view_mode") - - actual fun load(): SeasonViewMode? = - SeasonViewMode.parse(store.getString(ProfileScopedKey.of("season_view_mode"))) - - actual fun save(mode: SeasonViewMode) { - store.putString(ProfileScopedKey.of("season_view_mode"), SeasonViewMode.persist(mode)) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/components/HeroTrailerPlayerSurface.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/components/HeroTrailerPlayerSurface.desktop.kt deleted file mode 100644 index 85aeb501..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/components/HeroTrailerPlayerSurface.desktop.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.nuvio.app.features.details.components - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Modifier - -@Composable -actual fun HeroTrailerPlayerSurface( - sourceUrl: String, - sourceAudioUrl: String?, - playWhenReady: Boolean, - muted: Boolean, - modifier: Modifier, - onReady: () -> Unit, - onEnded: () -> Unit, - onError: () -> Unit, -) { - LaunchedEffect(sourceUrl) { - onError() - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsClock.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsClock.desktop.kt deleted file mode 100644 index 2a9a1c8e..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsClock.desktop.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.nuvio.app.features.downloads - -internal actual object DownloadsClock { - actual fun nowEpochMs(): Long = System.currentTimeMillis() -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsLiveStatusPlatform.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsLiveStatusPlatform.desktop.kt deleted file mode 100644 index 5e0e33f5..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsLiveStatusPlatform.desktop.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.nuvio.app.features.downloads - -internal actual object DownloadsLiveStatusPlatform { - actual fun onItemsChanged(items: List) = Unit -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.desktop.kt deleted file mode 100644 index 12097a65..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.desktop.kt +++ /dev/null @@ -1,193 +0,0 @@ -package com.nuvio.app.features.downloads - -import com.nuvio.app.core.storage.DesktopStorage -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.launch -import java.io.File -import java.io.FileOutputStream -import java.net.URI -import java.net.http.HttpClient -import java.net.http.HttpRequest -import java.net.http.HttpResponse -import java.time.Duration -import kotlin.io.path.createDirectories - -private val desktopDownloadHttpClient: HttpClient = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(60)) - .followRedirects(HttpClient.Redirect.NORMAL) - .build() - -internal actual object DownloadsPlatformDownloader { - private val downloadsDir: File - get() = File(DesktopStorage.rootDir.resolve("downloads").also { it.createDirectories() }.toUri()) - - actual fun start( - request: DownloadPlatformRequest, - onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit, - onSuccess: (localFileUri: String, totalBytes: Long?) -> Unit, - onFailure: (message: String) -> Unit, - ): DownloadsTaskHandle { - val job = SupervisorJob() - val scope = CoroutineScope(job + Dispatchers.IO) - - scope.launch { - val destination = File(downloadsDir, request.destinationFileName) - val tempFile = File(downloadsDir, "${request.destinationFileName}.part") - - try { - var resumeFromBytes = tempFile.takeIf { it.exists() }?.length()?.coerceAtLeast(0L) ?: 0L - var attemptedRangeRequest = resumeFromBytes > 0L - var response = sendDownloadRequest(request, if (attemptedRangeRequest) resumeFromBytes else null) - - if (attemptedRangeRequest && response.statusCode() == 416) { - tempFile.delete() - resumeFromBytes = 0L - attemptedRangeRequest = false - response = sendDownloadRequest(request, null) - } - - if (response.statusCode() !in 200..299) { - error("Download failed with HTTP ${response.statusCode()}") - } - - val isPartialResume = attemptedRangeRequest && response.statusCode() == 206 && resumeFromBytes > 0L - val appendToTemp = isPartialResume - val startingBytes = if (appendToTemp) resumeFromBytes else 0L - if (!appendToTemp && tempFile.exists()) { - tempFile.delete() - } - - val totalBytes = resolveTotalBytes( - startingBytes = startingBytes, - isPartialResume = isPartialResume, - contentRangeHeader = response.headers().firstValue("Content-Range").orElse(null), - contentLength = response.headers().firstValue("Content-Length").orElse(null)?.toLongOrNull(), - ) - var downloadedBytes = startingBytes - onProgress(downloadedBytes, totalBytes) - - response.body().use { input -> - FileOutputStream(tempFile, appendToTemp).use { output -> - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - while (true) { - ensureActive() - val read = input.read(buffer) - if (read <= 0) break - output.write(buffer, 0, read) - downloadedBytes += read.toLong() - onProgress(downloadedBytes, totalBytes) - } - output.flush() - } - } - - if (destination.exists()) { - destination.delete() - } - if (!tempFile.renameTo(destination)) { - tempFile.copyTo(destination, overwrite = true) - tempFile.delete() - } - - val finalSize = destination.length() - onSuccess(destination.toURI().toString(), totalBytes ?: finalSize) - } catch (error: CancellationException) { - throw error - } catch (error: Throwable) { - onFailure(error.message ?: "Download failed") - } - } - - return DesktopDownloadsTaskHandle(job) - } - - actual fun removeFile(localFileUri: String?): Boolean { - if (localFileUri.isNullOrBlank()) return false - val file = localFileUri.toLocalFileOrNull() ?: return false - return runCatching { file.delete() }.getOrDefault(false) - } - - actual fun removePartialFile(destinationFileName: String): Boolean { - val tempFile = File(downloadsDir, "$destinationFileName.part") - if (!tempFile.exists()) return true - return runCatching { tempFile.delete() }.getOrDefault(false) - } - - actual fun resolveLocalFileUri(localFileUri: String?, destinationFileName: String): String? { - localFileUri - ?.toLocalFileOrNull() - ?.takeIf { it.exists() } - ?.let { return it.toURI().toString() } - - val fileName = destinationFileName.trim().takeIf { it.isNotBlank() } - ?: localFileUri?.toLocalFileOrNull()?.name?.takeIf { it.isNotBlank() } - ?: return null - return File(downloadsDir, fileName).takeIf { it.exists() }?.toURI()?.toString() - } - - private fun sendDownloadRequest( - request: DownloadPlatformRequest, - rangeStart: Long?, - ): HttpResponse { - val builder = HttpRequest.newBuilder() - .uri(URI(request.sourceUrl)) - .timeout(Duration.ofSeconds(60)) - .GET() - request.sourceHeaders.forEach { (key, value) -> - if (key.isNotBlank() && value.isNotBlank()) { - builder.header(key, value) - } - } - if (rangeStart != null && rangeStart > 0L) { - builder.header("Range", "bytes=$rangeStart-") - } - return desktopDownloadHttpClient.send(builder.build(), HttpResponse.BodyHandlers.ofInputStream()) - } -} - -private class DesktopDownloadsTaskHandle( - private val job: Job, -) : DownloadsTaskHandle { - override fun cancel() { - job.cancel() - } -} - -private fun String.toLocalFileOrNull(): File? = - runCatching { - if (startsWith("file:")) { - File(URI(this)) - } else { - File(this) - } - }.getOrNull() - -private fun resolveTotalBytes( - startingBytes: Long, - isPartialResume: Boolean, - contentRangeHeader: String?, - contentLength: Long?, -): Long? { - parseContentRangeTotal(contentRangeHeader)?.let { return it } - val normalizedLength = contentLength?.takeIf { it > 0L } ?: return null - return if (isPartialResume && startingBytes > 0L) { - startingBytes + normalizedLength - } else { - normalizedLength - } -} - -private fun parseContentRangeTotal(headerValue: String?): Long? { - val value = headerValue?.trim().orEmpty() - if (value.isBlank()) return null - val slashIndex = value.lastIndexOf('/') - if (slashIndex == -1 || slashIndex == value.lastIndex) return null - val totalPart = value.substring(slashIndex + 1).trim() - if (totalPart == "*") return null - return totalPart.toLongOrNull()?.takeIf { it > 0L } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsStorage.desktop.kt deleted file mode 100644 index 04ac0d9e..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsStorage.desktop.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.nuvio.app.features.downloads - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey - -internal actual object DownloadsStorage { - private val store = DesktopStorage.store("nuvio_downloads") - - actual fun loadPayload(): String? = - store.getString(ProfileScopedKey.of("downloads")) - - actual fun savePayload(payload: String) { - store.putString(ProfileScopedKey.of("downloads"), payload) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsStorage.desktop.kt deleted file mode 100644 index 2972496e..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsStorage.desktop.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.nuvio.app.features.home - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey - -internal actual object HomeCatalogSettingsStorage { - private val store = DesktopStorage.store("nuvio_home_catalog_settings") - - actual fun loadPayload(): String? = - store.getString(ProfileScopedKey.of("home_catalog_settings")) - - actual fun savePayload(payload: String) { - store.putString(ProfileScopedKey.of("home_catalog_settings"), payload) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.desktop.kt deleted file mode 100644 index 4b2b4083..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.desktop.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.nuvio.app.features.home.components - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale -import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage -import coil3.compose.LocalPlatformContext -import coil3.request.ImageRequest - -@Composable -internal actual fun CollectionCardRemoteImage( - imageUrl: String, - contentDescription: String, - modifier: Modifier, - contentScale: ContentScale, - animateIfPossible: Boolean, -) { - val context = LocalPlatformContext.current - val request = remember(context, imageUrl) { - ImageRequest.Builder(context) - .data(imageUrl) - .memoryCacheKey("home-collection:$imageUrl") - .diskCacheKey(imageUrl) - .build() - } - - AsyncImage( - model = request, - contentDescription = contentDescription, - modifier = modifier, - contentScale = contentScale, - ) -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/library/LibraryClock.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/library/LibraryClock.desktop.kt deleted file mode 100644 index 4869d713..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/library/LibraryClock.desktop.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.nuvio.app.features.library - -internal actual object LibraryClock { - actual fun nowEpochMs(): Long = System.currentTimeMillis() -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/library/LibraryStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/library/LibraryStorage.desktop.kt deleted file mode 100644 index 659d5815..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/library/LibraryStorage.desktop.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.nuvio.app.features.library - -import com.nuvio.app.core.storage.DesktopStorage - -internal actual object LibraryStorage { - private val store = DesktopStorage.store("nuvio_library") - - actual fun loadPayload(profileId: Int): String? = - store.getString("library_$profileId") - - actual fun savePayload(profileId: Int, payload: String) { - store.putString("library_$profileId", payload) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.desktop.kt deleted file mode 100644 index 0d95cfeb..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.desktop.kt +++ /dev/null @@ -1,84 +0,0 @@ -package com.nuvio.app.features.mdblist - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey -import com.nuvio.app.core.sync.decodeSyncBoolean -import com.nuvio.app.core.sync.decodeSyncString -import com.nuvio.app.core.sync.encodeSyncBoolean -import com.nuvio.app.core.sync.encodeSyncString -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put - -internal actual object MdbListSettingsStorage { - private const val enabledKey = "mdblist_enabled" - private const val apiKey = "mdblist_api_key" - private const val useImdbKey = "mdblist_use_imdb" - private const val useTmdbKey = "mdblist_use_tmdb" - private const val useTomatoesKey = "mdblist_use_tomatoes" - private const val useMetacriticKey = "mdblist_use_metacritic" - private const val useTraktKey = "mdblist_use_trakt" - private const val useLetterboxdKey = "mdblist_use_letterboxd" - private const val useAudienceKey = "mdblist_use_audience" - private val syncKeys = listOf( - enabledKey, - apiKey, - useImdbKey, - useTmdbKey, - useTomatoesKey, - useMetacriticKey, - useTraktKey, - useLetterboxdKey, - useAudienceKey, - ) - private val store = DesktopStorage.store("nuvio_mdblist_settings") - - actual fun loadEnabled(): Boolean? = loadBoolean(enabledKey) - actual fun saveEnabled(enabled: Boolean) = saveBoolean(enabledKey, enabled) - actual fun loadApiKey(): String? = loadString(apiKey) - actual fun saveApiKey(apiKey: String) = saveString(this.apiKey, apiKey) - actual fun loadUseImdb(): Boolean? = loadBoolean(useImdbKey) - actual fun saveUseImdb(enabled: Boolean) = saveBoolean(useImdbKey, enabled) - actual fun loadUseTmdb(): Boolean? = loadBoolean(useTmdbKey) - actual fun saveUseTmdb(enabled: Boolean) = saveBoolean(useTmdbKey, enabled) - actual fun loadUseTomatoes(): Boolean? = loadBoolean(useTomatoesKey) - actual fun saveUseTomatoes(enabled: Boolean) = saveBoolean(useTomatoesKey, enabled) - actual fun loadUseMetacritic(): Boolean? = loadBoolean(useMetacriticKey) - actual fun saveUseMetacritic(enabled: Boolean) = saveBoolean(useMetacriticKey, enabled) - actual fun loadUseTrakt(): Boolean? = loadBoolean(useTraktKey) - actual fun saveUseTrakt(enabled: Boolean) = saveBoolean(useTraktKey, enabled) - actual fun loadUseLetterboxd(): Boolean? = loadBoolean(useLetterboxdKey) - actual fun saveUseLetterboxd(enabled: Boolean) = saveBoolean(useLetterboxdKey, enabled) - actual fun loadUseAudience(): Boolean? = loadBoolean(useAudienceKey) - actual fun saveUseAudience(enabled: Boolean) = saveBoolean(useAudienceKey, enabled) - - private fun loadString(key: String): String? = store.getString(ProfileScopedKey.of(key)) - private fun saveString(key: String, value: String) = store.putString(ProfileScopedKey.of(key), value) - private fun loadBoolean(key: String): Boolean? = store.getBoolean(ProfileScopedKey.of(key)) - private fun saveBoolean(key: String, value: Boolean) = store.putBoolean(ProfileScopedKey.of(key), value) - - actual fun exportToSyncPayload(): JsonObject = buildJsonObject { - loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) } - loadApiKey()?.let { put(apiKey, encodeSyncString(it)) } - loadUseImdb()?.let { put(useImdbKey, encodeSyncBoolean(it)) } - loadUseTmdb()?.let { put(useTmdbKey, encodeSyncBoolean(it)) } - loadUseTomatoes()?.let { put(useTomatoesKey, encodeSyncBoolean(it)) } - loadUseMetacritic()?.let { put(useMetacriticKey, encodeSyncBoolean(it)) } - loadUseTrakt()?.let { put(useTraktKey, encodeSyncBoolean(it)) } - loadUseLetterboxd()?.let { put(useLetterboxdKey, encodeSyncBoolean(it)) } - loadUseAudience()?.let { put(useAudienceKey, encodeSyncBoolean(it)) } - } - - actual fun replaceFromSyncPayload(payload: JsonObject) { - store.removeAll(syncKeys.map(ProfileScopedKey::of)) - payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled) - payload.decodeSyncString(apiKey)?.let(::saveApiKey) - payload.decodeSyncBoolean(useImdbKey)?.let(::saveUseImdb) - payload.decodeSyncBoolean(useTmdbKey)?.let(::saveUseTmdb) - payload.decodeSyncBoolean(useTomatoesKey)?.let(::saveUseTomatoes) - payload.decodeSyncBoolean(useMetacriticKey)?.let(::saveUseMetacritic) - payload.decodeSyncBoolean(useTraktKey)?.let(::saveUseTrakt) - payload.decodeSyncBoolean(useLetterboxdKey)?.let(::saveUseLetterboxd) - payload.decodeSyncBoolean(useAudienceKey)?.let(::saveUseAudience) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.desktop.kt deleted file mode 100644 index 6d464738..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.desktop.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.nuvio.app.features.notifications - -internal actual object EpisodeReleaseNotificationPlatform { - actual suspend fun notificationsAuthorized(): Boolean = false - - actual suspend fun requestAuthorization(): Boolean = false - - actual suspend fun scheduleEpisodeReleaseNotifications( - requests: List, - ) = Unit - - actual suspend fun clearScheduledEpisodeReleaseNotifications() = Unit - - actual suspend fun showTestNotification(request: EpisodeReleaseNotificationRequest) = Unit -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsClock.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsClock.desktop.kt deleted file mode 100644 index 3ee8ee44..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsClock.desktop.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.nuvio.app.features.notifications - -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter - -internal actual object EpisodeReleaseNotificationsClock { - private val formatter = DateTimeFormatter.ISO_LOCAL_DATE - - actual fun isoDateFromEpochMs(epochMs: Long): String = - Instant.ofEpochMilli(epochMs) - .atZone(ZoneId.systemDefault()) - .toLocalDate() - .format(formatter) -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsStorage.desktop.kt deleted file mode 100644 index 473aee28..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsStorage.desktop.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.nuvio.app.features.notifications - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey - -internal actual object EpisodeReleaseNotificationsStorage { - private val store = DesktopStorage.store("nuvio_episode_release_notifications") - - actual fun loadPayload(): String? = - store.getString(ProfileScopedKey.of("episode_release_notifications")) - - actual fun savePayload(payload: String) { - store.putString(ProfileScopedKey.of("episode_release_notifications"), payload) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/p2p/P2pSettingsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/p2p/P2pSettingsStorage.desktop.kt deleted file mode 100644 index e19e8912..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/p2p/P2pSettingsStorage.desktop.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.nuvio.app.features.p2p - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey - -internal actual object P2pSettingsStorage { - private const val p2pEnabledKey = "p2p_enabled" - private const val enableUploadKey = "enable_upload" - private const val hideTorrentStatsKey = "hide_torrent_stats" - private val store = DesktopStorage.store("torrent_settings") - - actual fun loadP2pEnabled(): Boolean? = loadBoolean(p2pEnabledKey) - actual fun saveP2pEnabled(enabled: Boolean) = saveBoolean(p2pEnabledKey, enabled) - actual fun loadEnableUpload(): Boolean? = loadBoolean(enableUploadKey) - actual fun saveEnableUpload(enabled: Boolean) = saveBoolean(enableUploadKey, enabled) - actual fun loadHideTorrentStats(): Boolean? = loadBoolean(hideTorrentStatsKey) - actual fun saveHideTorrentStats(enabled: Boolean) = saveBoolean(hideTorrentStatsKey, enabled) - - private fun loadBoolean(key: String): Boolean? = store.getBoolean(ProfileScopedKey.of(key)) - private fun saveBoolean(key: String, value: Boolean) = store.putBoolean(ProfileScopedKey.of(key), value) -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngine.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngine.desktop.kt deleted file mode 100644 index 5eebed0c..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngine.desktop.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.nuvio.app.features.p2p - -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow - -actual object P2pStreamingEngine { - private val _state = MutableStateFlow(P2pStreamingState.Idle) - - actual val state: StateFlow = _state.asStateFlow() - - actual suspend fun startStream(request: P2pStreamRequest): String { - _state.value = P2pStreamingState.Error("P2P streaming is not available on desktop yet.") - throw P2pStreamingException("P2P streaming is not available on desktop yet.") - } - - actual fun stopStream() { - _state.value = P2pStreamingState.Idle - } - - actual fun shutdown() { - _state.value = P2pStreamingState.Idle - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLauncherEffect.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLauncherEffect.desktop.kt deleted file mode 100644 index 280256c7..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLauncherEffect.desktop.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.nuvio.app.features.player - -import androidx.compose.runtime.Composable - -@Composable -actual fun rememberExternalPlayerLauncher( - onResult: (ExternalPlaybackResult?) -> Unit, -): (ExternalPlayerIntentResult.Success) -> Boolean = - { intentResult -> - val launched = ExternalPlayerPlatform.launch(intentResult.intent) - if (launched) { - onResult(null) - } - launched - } diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.desktop.kt deleted file mode 100644 index 6a9a820f..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.desktop.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.nuvio.app.features.player - -import java.awt.Desktop -import java.io.File -import java.net.URI - -private data class DesktopExternalPlayerIntent( - val request: ExternalPlayerPlaybackRequest, - val playerId: String?, -) - -internal actual object ExternalPlayerPlatform { - private const val systemPlayerId = "system" - - actual fun defaultPlayerId(): String? = systemPlayerId - - actual fun availablePlayers(): List = - listOf(ExternalPlayerApp(systemPlayerId, "System default")) - - actual fun open( - request: ExternalPlayerPlaybackRequest, - playerId: String?, - ): ExternalPlayerOpenResult = - if (openUri(request.sourceUrl)) { - ExternalPlayerOpenResult.Opened - } else { - ExternalPlayerOpenResult.Failed - } - - actual fun buildIntent( - request: ExternalPlayerPlaybackRequest, - playerId: String?, - ): ExternalPlayerIntentResult = - ExternalPlayerIntentResult.Success(DesktopExternalPlayerIntent(request, playerId)) - - internal fun launch(intent: Any): Boolean { - val desktopIntent = intent as? DesktopExternalPlayerIntent ?: return false - return open(desktopIntent.request, desktopIntent.playerId) == ExternalPlayerOpenResult.Opened - } - - private fun openUri(rawUri: String): Boolean { - val uri = runCatching { URI(rawUri) }.getOrNull() ?: return false - val desktop = runCatching { Desktop.getDesktop() }.getOrNull() - - if (desktop != null && Desktop.isDesktopSupported()) { - val opened = runCatching { - if (uri.scheme.equals("file", ignoreCase = true)) { - desktop.open(File(uri)) - } else { - desktop.browse(uri) - } - }.isSuccess - if (opened) return true - } - - return openWithPlatformCommand(rawUri) - } - - private fun openWithPlatformCommand(rawUri: String): Boolean { - val osName = System.getProperty("os.name").orEmpty().lowercase() - val command = when { - osName.contains("mac") -> listOf("open", rawUri) - osName.contains("win") -> listOf("rundll32", "url.dll,FileProtocolHandler", rawUri) - else -> listOf("xdg-open", rawUri) - } - return runCatching { ProcessBuilder(command).start() }.isSuccess - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerEngine.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerEngine.desktop.kt deleted file mode 100644 index b1c20ca2..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerEngine.desktop.kt +++ /dev/null @@ -1,243 +0,0 @@ -package com.nuvio.app.features.player - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.ui.awt.SwingPanel -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import com.nuvio.app.features.player.desktop.DesktopHostOs -import com.nuvio.app.features.player.desktop.DesktopPlayerLaunchShield -import com.nuvio.app.features.player.desktop.NativePlayerController -import com.nuvio.app.features.player.desktop.NativePlayerHost -import kotlinx.coroutines.delay - -@Composable -actual fun PlatformPlayerSurface( - sourceUrl: String, - sourceAudioUrl: String?, - sourceHeaders: Map, - sourceResponseHeaders: Map, - useYoutubeChunkedPlayback: Boolean, - modifier: Modifier, - playWhenReady: Boolean, - resizeMode: PlayerResizeMode, - initialPositionMs: Long, - useNativeController: Boolean, - playerControlsState: PlayerControlsState, - onPlayerControlsAction: (PlayerControlsAction) -> Boolean, - onPlayerControlsEvent: (String, Double) -> Boolean, - onPlayerControlsScrubChange: (Long) -> Boolean, - onPlayerControlsScrubFinished: (Long) -> Boolean, - onControllerReady: (PlayerEngineController) -> Unit, - onSnapshot: (PlayerPlaybackSnapshot) -> Unit, - onError: (String?) -> Unit, -) { - if (DesktopHostOs.current == DesktopHostOs.MACOS || DesktopHostOs.current == DesktopHostOs.WINDOWS) { - NativePlayerSurface( - sourceUrl = sourceUrl, - sourceHeaders = sourceHeaders, - modifier = modifier, - playWhenReady = playWhenReady, - resizeMode = resizeMode, - initialPositionMs = initialPositionMs, - playerControlsState = playerControlsState, - onPlayerControlsAction = onPlayerControlsAction, - onPlayerControlsEvent = onPlayerControlsEvent, - onPlayerControlsScrubChange = onPlayerControlsScrubChange, - onPlayerControlsScrubFinished = onPlayerControlsScrubFinished, - onControllerReady = onControllerReady, - onSnapshot = onSnapshot, - onError = onError, - ) - return - } - - DesktopStubPlayerSurface( - modifier = modifier, - onControllerReady = onControllerReady, - onSnapshot = onSnapshot, - ) -} - -@Composable -private fun NativePlayerSurface( - sourceUrl: String, - sourceHeaders: Map, - modifier: Modifier, - playWhenReady: Boolean, - resizeMode: PlayerResizeMode, - initialPositionMs: Long, - playerControlsState: PlayerControlsState, - onPlayerControlsAction: (PlayerControlsAction) -> Boolean, - onPlayerControlsEvent: (String, Double) -> Boolean, - onPlayerControlsScrubChange: (Long) -> Boolean, - onPlayerControlsScrubFinished: (Long) -> Boolean, - onControllerReady: (PlayerEngineController) -> Unit, - onSnapshot: (PlayerPlaybackSnapshot) -> Unit, - onError: (String?) -> Unit, -) { - val host = remember { NativePlayerHost() } - val controller = remember(host) { NativePlayerController(host) } - val hostFirstPaintComplete = remember { mutableStateOf(false) } - val hostFirstFullSizePaintComplete = remember { mutableStateOf(false) } - LaunchedEffect(sourceUrl) { - DesktopPlayerLaunchShield.showForActiveWindow() - } - val playbackHeaders = remember(sourceHeaders) { sanitizePlaybackHeaders(sourceHeaders) } - val latestOnPlayerControlsAction = rememberUpdatedState(onPlayerControlsAction) - val latestOnPlayerControlsEvent = rememberUpdatedState(onPlayerControlsEvent) - val latestOnPlayerControlsScrubChange = rememberUpdatedState(onPlayerControlsScrubChange) - val latestOnPlayerControlsScrubFinished = rememberUpdatedState(onPlayerControlsScrubFinished) - val latestOnError = rememberUpdatedState(onError) - - LaunchedEffect(controller) { - onControllerReady(controller) - } - - DisposableEffect(host) { - host.onDisplayableChanged = { displayable -> - if (!displayable) { - hostFirstPaintComplete.value = false - hostFirstFullSizePaintComplete.value = false - } - } - host.onFirstPaint = { - hostFirstPaintComplete.value = true - } - host.onFirstFullSizePaint = { - hostFirstFullSizePaintComplete.value = true - DesktopPlayerLaunchShield.hideAfter() - } - onDispose { - host.onDisplayableChanged = null - host.onFirstPaint = null - host.onFirstFullSizePaint = null - DesktopPlayerLaunchShield.hide() - } - } - - LaunchedEffect(controller) { - controller.setControlCallbacks( - onAction = { action -> latestOnPlayerControlsAction.value(action) }, - onEvent = { type, value -> latestOnPlayerControlsEvent.value(type, value) }, - onScrubChange = { positionMs -> latestOnPlayerControlsScrubChange.value(positionMs) }, - onScrubFinished = { positionMs -> latestOnPlayerControlsScrubFinished.value(positionMs) }, - ) - } - - DisposableEffect(controller, sourceUrl, playbackHeaders) { - onDispose { controller.dispose() } - } - - LaunchedEffect(controller, sourceUrl, playbackHeaders, hostFirstFullSizePaintComplete.value) { - if (!hostFirstFullSizePaintComplete.value) { - return@LaunchedEffect - } - delay(16L) - controller.attach( - sourceUrl = sourceUrl, - sourceHeaders = playbackHeaders, - playWhenReady = playWhenReady, - initialPositionMs = initialPositionMs, - onError = { message -> latestOnError.value(message) }, - ) - } - - LaunchedEffect(controller, playWhenReady) { - if (playWhenReady) { - controller.play() - } else { - controller.pause() - } - } - - LaunchedEffect(controller, resizeMode) { - controller.setResizeMode(resizeMode) - } - - LaunchedEffect(controller, playerControlsState) { - controller.updateControls(playerControlsState) - } - - LaunchedEffect(controller) { - while (true) { - onSnapshot(controller.snapshot()) - delay(500L) - } - } - - Box( - modifier = modifier - .fillMaxSize() - .background(Color.Black), - ) { - SwingPanel( - factory = { - host - }, - modifier = if (hostFirstPaintComplete.value) { - Modifier.fillMaxSize() - } else { - Modifier - .align(Alignment.BottomEnd) - .requiredSize(1.dp) - }, - background = Color.Black, - ) - } -} - -@Composable -private fun DesktopStubPlayerSurface( - modifier: Modifier, - onControllerReady: (PlayerEngineController) -> Unit, - onSnapshot: (PlayerPlaybackSnapshot) -> Unit, -) { - val controller = remember { DesktopStubPlayerController() } - - LaunchedEffect(controller) { - onControllerReady(controller) - onSnapshot(PlayerPlaybackSnapshot(isLoading = false)) - } - - Box( - modifier = modifier - .fillMaxSize() - .background(Color.Black), - contentAlignment = Alignment.Center, - ) { - Text( - text = "Desktop in-app playback is not available yet.", - color = Color.White, - style = MaterialTheme.typography.bodyMedium, - ) - } -} - -private class DesktopStubPlayerController : PlayerEngineController { - override fun play() = Unit - override fun pause() = Unit - override fun seekTo(positionMs: Long) = Unit - override fun seekBy(offsetMs: Long) = Unit - override fun retry() = Unit - override fun setPlaybackSpeed(speed: Float) = Unit - override fun getAudioTracks(): List = emptyList() - override fun getSubtitleTracks(): List = emptyList() - override fun selectAudioTrack(index: Int) = Unit - override fun selectSubtitleTrack(index: Int) = Unit - override fun setSubtitleUri(url: String) = Unit - override fun clearExternalSubtitle() = Unit - override fun clearExternalSubtitleAndSelect(trackIndex: Int) = Unit -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerLanguagePreferences.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerLanguagePreferences.desktop.kt deleted file mode 100644 index 3dd1535b..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerLanguagePreferences.desktop.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.nuvio.app.features.player - -import java.util.Locale - -internal actual object DeviceLanguagePreferences { - actual fun preferredLanguageCodes(): List = - Locale.getDefault() - .toLanguageTag() - .takeIf { it.isNotBlank() } - ?.let(::listOf) - ?: emptyList() -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.desktop.kt deleted file mode 100644 index 91ad9cef..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.desktop.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.nuvio.app.features.player - -import androidx.compose.runtime.Composable -import androidx.compose.ui.unit.IntSize - -@Composable -actual fun LockPlayerToLandscape() = Unit - -@Composable -actual fun EnterImmersivePlayerMode(keepScreenAwake: Boolean) = Unit - -@Composable -actual fun ManagePlayerPictureInPicture( - isPlaying: Boolean, - playerSize: IntSize, -) = Unit - -@Composable -actual fun rememberPlayerGestureController(): PlayerGestureController? = null diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.desktop.kt deleted file mode 100644 index 1498a959..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.desktop.kt +++ /dev/null @@ -1,406 +0,0 @@ -package com.nuvio.app.features.player - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey -import com.nuvio.app.core.sync.decodeSyncBoolean -import com.nuvio.app.core.sync.decodeSyncFloat -import com.nuvio.app.core.sync.decodeSyncInt -import com.nuvio.app.core.sync.decodeSyncString -import com.nuvio.app.core.sync.decodeSyncStringSet -import com.nuvio.app.core.sync.encodeSyncBoolean -import com.nuvio.app.core.sync.encodeSyncFloat -import com.nuvio.app.core.sync.encodeSyncInt -import com.nuvio.app.core.sync.encodeSyncString -import com.nuvio.app.core.sync.encodeSyncStringSet -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put - -internal actual object PlayerSettingsStorage { - private const val showLoadingOverlayKey = "show_loading_overlay" - private const val resizeModeKey = "resize_mode" - private const val holdToSpeedEnabledKey = "hold_to_speed_enabled" - private const val holdToSpeedValueKey = "hold_to_speed_value" - private const val externalPlayerEnabledKey = "external_player_enabled" - private const val externalPlayerForwardSubtitlesKey = "external_player_forward_subtitles" - private const val externalPlayerIdKey = "external_player_id" - private const val preferredAudioLanguageKey = "preferred_audio_language" - private const val secondaryPreferredAudioLanguageKey = "secondary_preferred_audio_language" - private const val preferredSubtitleLanguageKey = "preferred_subtitle_language" - private const val secondaryPreferredSubtitleLanguageKey = "secondary_preferred_subtitle_language" - private const val subtitleTextColorKey = "subtitle_text_color" - private const val subtitleBackgroundColorKey = "subtitle_background_color" - private const val subtitleOutlineColorKey = "subtitle_outline_color" - private const val subtitleOutlineEnabledKey = "subtitle_outline_enabled" - private const val subtitleOutlineWidthKey = "subtitle_outline_width" - private const val subtitleBoldKey = "subtitle_bold" - private const val subtitleFontSizeSpKey = "subtitle_font_size_sp" - private const val subtitleBottomOffsetKey = "subtitle_bottom_offset" - private const val subtitleUseForcedSubtitlesKey = "subtitle_use_forced_subtitles" - private const val subtitleShowOnlyPreferredLanguagesKey = "subtitle_show_only_preferred_languages" - private const val addonSubtitleStartupModeKey = "addon_subtitle_startup_mode" - private const val streamReuseLastLinkEnabledKey = "stream_reuse_last_link_enabled" - private const val streamReuseLastLinkCacheHoursKey = "stream_reuse_last_link_cache_hours" - private const val decoderPriorityKey = "decoder_priority" - private const val mapDV7ToHevcKey = "map_dv7_to_hevc" - private const val tunnelingEnabledKey = "tunneling_enabled" - private const val streamAutoPlayModeKey = "stream_auto_play_mode" - private const val streamAutoPlaySourceKey = "stream_auto_play_source" - private const val streamAutoPlaySelectedAddonsKey = "stream_auto_play_selected_addons" - private const val streamAutoPlaySelectedPluginsKey = "stream_auto_play_selected_plugins" - private const val streamAutoPlayRegexKey = "stream_auto_play_regex" - private const val streamAutoPlayTimeoutSecondsKey = "stream_auto_play_timeout_seconds" - private const val skipIntroEnabledKey = "skip_intro_enabled" - private const val animeSkipEnabledKey = "animeskip_enabled" - private const val animeSkipClientIdKey = "animeskip_client_id" - private const val introDbApiKeyKey = "introdb_api_key" - private const val introSubmitEnabledKey = "intro_submit_enabled" - private const val streamAutoPlayNextEpisodeEnabledKey = "stream_auto_play_next_episode_enabled" - private const val streamAutoPlayPreferBingeGroupKey = "stream_auto_play_prefer_binge_group" - private const val streamAutoPlayReuseBingeGroupKey = "stream_auto_play_reuse_binge_group" - private const val nextEpisodeThresholdModeKey = "next_episode_threshold_mode" - private const val nextEpisodeThresholdPercentKey = "next_episode_threshold_percent_v2" - private const val nextEpisodeThresholdMinutesBeforeEndKey = "next_episode_threshold_minutes_before_end_v2" - private const val useLibassKey = "use_libass" - private const val libassRenderTypeKey = "libass_render_type" - private const val iosVideoOutputPresetKey = "ios_video_output_preset" - private const val iosToneMappingModeKey = "ios_tone_mapping_mode" - private const val iosTargetPrimariesKey = "ios_target_primaries" - private const val iosTargetTransferKey = "ios_target_transfer" - private const val iosHardwareDecoderModeKey = "ios_hardware_decoder_mode" - private const val iosAudioOutputModeKey = "ios_audio_output_mode" - private const val iosExtendedDynamicRangeEnabledKey = "ios_extended_dynamic_range_enabled" - private const val iosTargetColorspaceHintEnabledKey = "ios_target_colorspace_hint_enabled" - private const val iosHdrComputePeakEnabledKey = "ios_hdr_compute_peak_enabled" - private const val iosDebandEnabledKey = "ios_deband_enabled" - private const val iosInterpolationEnabledKey = "ios_interpolation_enabled" - private const val iosBrightnessKey = "ios_brightness" - private const val iosContrastKey = "ios_contrast" - private const val iosSaturationKey = "ios_saturation" - private const val iosGammaKey = "ios_gamma" - private val syncKeys = listOf( - showLoadingOverlayKey, - resizeModeKey, - holdToSpeedEnabledKey, - holdToSpeedValueKey, - externalPlayerEnabledKey, - externalPlayerForwardSubtitlesKey, - externalPlayerIdKey, - preferredAudioLanguageKey, - secondaryPreferredAudioLanguageKey, - preferredSubtitleLanguageKey, - secondaryPreferredSubtitleLanguageKey, - subtitleTextColorKey, - subtitleBackgroundColorKey, - subtitleOutlineColorKey, - subtitleOutlineEnabledKey, - subtitleOutlineWidthKey, - subtitleBoldKey, - subtitleFontSizeSpKey, - subtitleBottomOffsetKey, - subtitleUseForcedSubtitlesKey, - subtitleShowOnlyPreferredLanguagesKey, - addonSubtitleStartupModeKey, - streamReuseLastLinkEnabledKey, - streamReuseLastLinkCacheHoursKey, - decoderPriorityKey, - mapDV7ToHevcKey, - tunnelingEnabledKey, - streamAutoPlayModeKey, - streamAutoPlaySourceKey, - streamAutoPlaySelectedAddonsKey, - streamAutoPlaySelectedPluginsKey, - streamAutoPlayRegexKey, - streamAutoPlayTimeoutSecondsKey, - skipIntroEnabledKey, - animeSkipEnabledKey, - animeSkipClientIdKey, - streamAutoPlayNextEpisodeEnabledKey, - streamAutoPlayPreferBingeGroupKey, - streamAutoPlayReuseBingeGroupKey, - nextEpisodeThresholdModeKey, - nextEpisodeThresholdPercentKey, - nextEpisodeThresholdMinutesBeforeEndKey, - useLibassKey, - libassRenderTypeKey, - iosVideoOutputPresetKey, - iosToneMappingModeKey, - iosTargetPrimariesKey, - iosTargetTransferKey, - iosHardwareDecoderModeKey, - iosAudioOutputModeKey, - iosExtendedDynamicRangeEnabledKey, - iosTargetColorspaceHintEnabledKey, - iosHdrComputePeakEnabledKey, - iosDebandEnabledKey, - iosInterpolationEnabledKey, - iosBrightnessKey, - iosContrastKey, - iosSaturationKey, - iosGammaKey, - ) - private val store = DesktopStorage.store("nuvio_player_settings") - - actual fun loadShowLoadingOverlay(): Boolean? = loadBoolean(showLoadingOverlayKey) - actual fun saveShowLoadingOverlay(enabled: Boolean) = saveBoolean(showLoadingOverlayKey, enabled) - actual fun loadResizeMode(): String? = loadString(resizeModeKey) - actual fun saveResizeMode(mode: String) = saveString(resizeModeKey, mode) - actual fun loadHoldToSpeedEnabled(): Boolean? = loadBoolean(holdToSpeedEnabledKey) - actual fun saveHoldToSpeedEnabled(enabled: Boolean) = saveBoolean(holdToSpeedEnabledKey, enabled) - actual fun loadHoldToSpeedValue(): Float? = loadFloat(holdToSpeedValueKey) - actual fun saveHoldToSpeedValue(speed: Float) = saveFloat(holdToSpeedValueKey, speed) - actual fun loadExternalPlayerEnabled(): Boolean? = loadBoolean(externalPlayerEnabledKey) - actual fun saveExternalPlayerEnabled(enabled: Boolean) = saveBoolean(externalPlayerEnabledKey, enabled) - actual fun loadExternalPlayerForwardSubtitles(): Boolean? = loadBoolean(externalPlayerForwardSubtitlesKey) - actual fun saveExternalPlayerForwardSubtitles(enabled: Boolean) = saveBoolean(externalPlayerForwardSubtitlesKey, enabled) - actual fun loadExternalPlayerId(): String? = loadString(externalPlayerIdKey) - actual fun saveExternalPlayerId(playerId: String?) = saveOptionalString(externalPlayerIdKey, playerId) - actual fun loadPreferredAudioLanguage(): String? = loadString(preferredAudioLanguageKey) - actual fun savePreferredAudioLanguage(language: String) = saveString(preferredAudioLanguageKey, language) - actual fun loadSecondaryPreferredAudioLanguage(): String? = loadString(secondaryPreferredAudioLanguageKey) - actual fun saveSecondaryPreferredAudioLanguage(language: String?) = saveOptionalString(secondaryPreferredAudioLanguageKey, language) - actual fun loadPreferredSubtitleLanguage(): String? = loadString(preferredSubtitleLanguageKey) - actual fun savePreferredSubtitleLanguage(language: String) = saveString(preferredSubtitleLanguageKey, language) - actual fun loadSecondaryPreferredSubtitleLanguage(): String? = loadString(secondaryPreferredSubtitleLanguageKey) - actual fun saveSecondaryPreferredSubtitleLanguage(language: String?) = saveOptionalString(secondaryPreferredSubtitleLanguageKey, language) - actual fun loadSubtitleTextColor(): String? = loadString(subtitleTextColorKey) - actual fun saveSubtitleTextColor(colorHex: String) = saveString(subtitleTextColorKey, colorHex) - actual fun loadSubtitleBackgroundColor(): String? = loadString(subtitleBackgroundColorKey) - actual fun saveSubtitleBackgroundColor(colorHex: String) = saveString(subtitleBackgroundColorKey, colorHex) - actual fun loadSubtitleOutlineColor(): String? = loadString(subtitleOutlineColorKey) - actual fun saveSubtitleOutlineColor(colorHex: String) = saveString(subtitleOutlineColorKey, colorHex) - actual fun loadSubtitleOutlineEnabled(): Boolean? = loadBoolean(subtitleOutlineEnabledKey) - actual fun saveSubtitleOutlineEnabled(enabled: Boolean) = saveBoolean(subtitleOutlineEnabledKey, enabled) - actual fun loadSubtitleOutlineWidth(): Int? = loadInt(subtitleOutlineWidthKey) - actual fun saveSubtitleOutlineWidth(width: Int) = saveInt(subtitleOutlineWidthKey, width) - actual fun loadSubtitleBold(): Boolean? = loadBoolean(subtitleBoldKey) - actual fun saveSubtitleBold(enabled: Boolean) = saveBoolean(subtitleBoldKey, enabled) - actual fun loadSubtitleFontSizeSp(): Int? = loadInt(subtitleFontSizeSpKey) - actual fun saveSubtitleFontSizeSp(fontSizeSp: Int) = saveInt(subtitleFontSizeSpKey, fontSizeSp) - actual fun loadSubtitleBottomOffset(): Int? = loadInt(subtitleBottomOffsetKey) - actual fun saveSubtitleBottomOffset(bottomOffset: Int) = saveInt(subtitleBottomOffsetKey, bottomOffset) - actual fun loadSubtitleUseForcedSubtitles(): Boolean? = loadBoolean(subtitleUseForcedSubtitlesKey) - actual fun saveSubtitleUseForcedSubtitles(enabled: Boolean) = saveBoolean(subtitleUseForcedSubtitlesKey, enabled) - actual fun loadSubtitleShowOnlyPreferredLanguages(): Boolean? = loadBoolean(subtitleShowOnlyPreferredLanguagesKey) - actual fun saveSubtitleShowOnlyPreferredLanguages(enabled: Boolean) = saveBoolean(subtitleShowOnlyPreferredLanguagesKey, enabled) - actual fun loadAddonSubtitleStartupMode(): String? = loadString(addonSubtitleStartupModeKey) - actual fun saveAddonSubtitleStartupMode(mode: String) = saveString(addonSubtitleStartupModeKey, mode) - actual fun loadStreamReuseLastLinkEnabled(): Boolean? = loadBoolean(streamReuseLastLinkEnabledKey) - actual fun saveStreamReuseLastLinkEnabled(enabled: Boolean) = saveBoolean(streamReuseLastLinkEnabledKey, enabled) - actual fun loadStreamReuseLastLinkCacheHours(): Int? = loadInt(streamReuseLastLinkCacheHoursKey) - actual fun saveStreamReuseLastLinkCacheHours(hours: Int) = saveInt(streamReuseLastLinkCacheHoursKey, hours) - actual fun loadDecoderPriority(): Int? = loadInt(decoderPriorityKey) - actual fun saveDecoderPriority(priority: Int) = saveInt(decoderPriorityKey, priority) - actual fun loadMapDV7ToHevc(): Boolean? = loadBoolean(mapDV7ToHevcKey) - actual fun saveMapDV7ToHevc(enabled: Boolean) = saveBoolean(mapDV7ToHevcKey, enabled) - actual fun loadTunnelingEnabled(): Boolean? = loadBoolean(tunnelingEnabledKey) - actual fun saveTunnelingEnabled(enabled: Boolean) = saveBoolean(tunnelingEnabledKey, enabled) - actual fun loadStreamAutoPlayMode(): String? = loadString(streamAutoPlayModeKey) - actual fun saveStreamAutoPlayMode(mode: String) = saveString(streamAutoPlayModeKey, mode) - actual fun loadStreamAutoPlaySource(): String? = loadString(streamAutoPlaySourceKey) - actual fun saveStreamAutoPlaySource(source: String) = saveString(streamAutoPlaySourceKey, source) - actual fun loadStreamAutoPlaySelectedAddons(): Set? = loadStringSet(streamAutoPlaySelectedAddonsKey) - actual fun saveStreamAutoPlaySelectedAddons(addons: Set) = saveStringSet(streamAutoPlaySelectedAddonsKey, addons) - actual fun loadStreamAutoPlaySelectedPlugins(): Set? = loadStringSet(streamAutoPlaySelectedPluginsKey) - actual fun saveStreamAutoPlaySelectedPlugins(plugins: Set) = saveStringSet(streamAutoPlaySelectedPluginsKey, plugins) - actual fun loadStreamAutoPlayRegex(): String? = loadString(streamAutoPlayRegexKey) - actual fun saveStreamAutoPlayRegex(regex: String) = saveString(streamAutoPlayRegexKey, regex) - actual fun loadStreamAutoPlayTimeoutSeconds(): Int? = loadInt(streamAutoPlayTimeoutSecondsKey) - actual fun saveStreamAutoPlayTimeoutSeconds(seconds: Int) = saveInt(streamAutoPlayTimeoutSecondsKey, seconds) - actual fun loadSkipIntroEnabled(): Boolean? = loadBoolean(skipIntroEnabledKey) - actual fun saveSkipIntroEnabled(enabled: Boolean) = saveBoolean(skipIntroEnabledKey, enabled) - actual fun loadAnimeSkipEnabled(): Boolean? = loadBoolean(animeSkipEnabledKey) - actual fun saveAnimeSkipEnabled(enabled: Boolean) = saveBoolean(animeSkipEnabledKey, enabled) - actual fun loadAnimeSkipClientId(): String? = loadString(animeSkipClientIdKey) - actual fun saveAnimeSkipClientId(clientId: String) = saveString(animeSkipClientIdKey, clientId) - actual fun loadIntroDbApiKey(): String? = loadString(introDbApiKeyKey) - actual fun saveIntroDbApiKey(apiKey: String) = saveString(introDbApiKeyKey, apiKey) - actual fun loadIntroSubmitEnabled(): Boolean? = loadBoolean(introSubmitEnabledKey) - actual fun saveIntroSubmitEnabled(enabled: Boolean) = saveBoolean(introSubmitEnabledKey, enabled) - actual fun loadStreamAutoPlayNextEpisodeEnabled(): Boolean? = loadBoolean(streamAutoPlayNextEpisodeEnabledKey) - actual fun saveStreamAutoPlayNextEpisodeEnabled(enabled: Boolean) = saveBoolean(streamAutoPlayNextEpisodeEnabledKey, enabled) - actual fun loadStreamAutoPlayPreferBingeGroup(): Boolean? = loadBoolean(streamAutoPlayPreferBingeGroupKey) - actual fun saveStreamAutoPlayPreferBingeGroup(enabled: Boolean) = saveBoolean(streamAutoPlayPreferBingeGroupKey, enabled) - actual fun loadStreamAutoPlayReuseBingeGroup(): Boolean? = loadBoolean(streamAutoPlayReuseBingeGroupKey) - actual fun saveStreamAutoPlayReuseBingeGroup(enabled: Boolean) = saveBoolean(streamAutoPlayReuseBingeGroupKey, enabled) - actual fun loadNextEpisodeThresholdMode(): String? = loadString(nextEpisodeThresholdModeKey) - actual fun saveNextEpisodeThresholdMode(mode: String) = saveString(nextEpisodeThresholdModeKey, mode) - actual fun loadNextEpisodeThresholdPercent(): Float? = loadFloat(nextEpisodeThresholdPercentKey) - actual fun saveNextEpisodeThresholdPercent(percent: Float) = saveFloat(nextEpisodeThresholdPercentKey, percent) - actual fun loadNextEpisodeThresholdMinutesBeforeEnd(): Float? = loadFloat(nextEpisodeThresholdMinutesBeforeEndKey) - actual fun saveNextEpisodeThresholdMinutesBeforeEnd(minutes: Float) = saveFloat(nextEpisodeThresholdMinutesBeforeEndKey, minutes) - actual fun loadUseLibass(): Boolean? = loadBoolean(useLibassKey) - actual fun saveUseLibass(enabled: Boolean) = saveBoolean(useLibassKey, enabled) - actual fun loadLibassRenderType(): String? = loadString(libassRenderTypeKey) - actual fun saveLibassRenderType(renderType: String) = saveString(libassRenderTypeKey, renderType) - actual fun loadIosVideoOutputPreset(): String? = loadString(iosVideoOutputPresetKey) - actual fun saveIosVideoOutputPreset(preset: String) = saveString(iosVideoOutputPresetKey, preset) - actual fun loadIosToneMappingMode(): String? = loadString(iosToneMappingModeKey) - actual fun saveIosToneMappingMode(mode: String) = saveString(iosToneMappingModeKey, mode) - actual fun loadIosTargetPrimaries(): String? = loadString(iosTargetPrimariesKey) - actual fun saveIosTargetPrimaries(primaries: String) = saveString(iosTargetPrimariesKey, primaries) - actual fun loadIosTargetTransfer(): String? = loadString(iosTargetTransferKey) - actual fun saveIosTargetTransfer(transfer: String) = saveString(iosTargetTransferKey, transfer) - actual fun loadIosHardwareDecoderMode(): String? = loadString(iosHardwareDecoderModeKey) - actual fun saveIosHardwareDecoderMode(mode: String) = saveString(iosHardwareDecoderModeKey, mode) - actual fun loadIosAudioOutputMode(): String? = loadString(iosAudioOutputModeKey) - actual fun saveIosAudioOutputMode(mode: String) = saveString(iosAudioOutputModeKey, mode) - actual fun loadIosExtendedDynamicRangeEnabled(): Boolean? = loadBoolean(iosExtendedDynamicRangeEnabledKey) - actual fun saveIosExtendedDynamicRangeEnabled(enabled: Boolean) = saveBoolean(iosExtendedDynamicRangeEnabledKey, enabled) - actual fun loadIosTargetColorspaceHintEnabled(): Boolean? = loadBoolean(iosTargetColorspaceHintEnabledKey) - actual fun saveIosTargetColorspaceHintEnabled(enabled: Boolean) = saveBoolean(iosTargetColorspaceHintEnabledKey, enabled) - actual fun loadIosHdrComputePeakEnabled(): Boolean? = loadBoolean(iosHdrComputePeakEnabledKey) - actual fun saveIosHdrComputePeakEnabled(enabled: Boolean) = saveBoolean(iosHdrComputePeakEnabledKey, enabled) - actual fun loadIosDebandEnabled(): Boolean? = loadBoolean(iosDebandEnabledKey) - actual fun saveIosDebandEnabled(enabled: Boolean) = saveBoolean(iosDebandEnabledKey, enabled) - actual fun loadIosInterpolationEnabled(): Boolean? = loadBoolean(iosInterpolationEnabledKey) - actual fun saveIosInterpolationEnabled(enabled: Boolean) = saveBoolean(iosInterpolationEnabledKey, enabled) - actual fun loadIosBrightness(): Int? = loadInt(iosBrightnessKey) - actual fun saveIosBrightness(value: Int) = saveInt(iosBrightnessKey, value) - actual fun loadIosContrast(): Int? = loadInt(iosContrastKey) - actual fun saveIosContrast(value: Int) = saveInt(iosContrastKey, value) - actual fun loadIosSaturation(): Int? = loadInt(iosSaturationKey) - actual fun saveIosSaturation(value: Int) = saveInt(iosSaturationKey, value) - actual fun loadIosGamma(): Int? = loadInt(iosGammaKey) - actual fun saveIosGamma(value: Int) = saveInt(iosGammaKey, value) - - private fun scoped(key: String): String = ProfileScopedKey.of(key) - private fun loadString(key: String): String? = store.getString(scoped(key)) - private fun saveString(key: String, value: String) = store.putString(scoped(key), value) - private fun saveOptionalString(key: String, value: String?) = store.putString(scoped(key), value?.takeIf { it.isNotBlank() }) - private fun loadBoolean(key: String): Boolean? = store.getBoolean(scoped(key)) - private fun saveBoolean(key: String, value: Boolean) = store.putBoolean(scoped(key), value) - private fun loadInt(key: String): Int? = store.getInt(scoped(key)) - private fun saveInt(key: String, value: Int) = store.putInt(scoped(key), value) - private fun loadFloat(key: String): Float? = store.getFloat(scoped(key)) - private fun saveFloat(key: String, value: Float) = store.putFloat(scoped(key), value) - private fun loadStringSet(key: String): Set? = store.getStringSet(scoped(key)) - private fun saveStringSet(key: String, values: Set) = store.putStringSet(scoped(key), values) - - actual fun exportToSyncPayload(): JsonObject = buildJsonObject { - loadShowLoadingOverlay()?.let { put(showLoadingOverlayKey, encodeSyncBoolean(it)) } - loadResizeMode()?.let { put(resizeModeKey, encodeSyncString(it)) } - loadHoldToSpeedEnabled()?.let { put(holdToSpeedEnabledKey, encodeSyncBoolean(it)) } - loadHoldToSpeedValue()?.let { put(holdToSpeedValueKey, encodeSyncFloat(it)) } - loadExternalPlayerEnabled()?.let { put(externalPlayerEnabledKey, encodeSyncBoolean(it)) } - loadExternalPlayerForwardSubtitles()?.let { put(externalPlayerForwardSubtitlesKey, encodeSyncBoolean(it)) } - loadExternalPlayerId()?.let { put(externalPlayerIdKey, encodeSyncString(it)) } - loadPreferredAudioLanguage()?.let { put(preferredAudioLanguageKey, encodeSyncString(it)) } - loadSecondaryPreferredAudioLanguage()?.let { put(secondaryPreferredAudioLanguageKey, encodeSyncString(it)) } - loadPreferredSubtitleLanguage()?.let { put(preferredSubtitleLanguageKey, encodeSyncString(it)) } - loadSecondaryPreferredSubtitleLanguage()?.let { put(secondaryPreferredSubtitleLanguageKey, encodeSyncString(it)) } - loadSubtitleTextColor()?.let { put(subtitleTextColorKey, encodeSyncString(it)) } - loadSubtitleBackgroundColor()?.let { put(subtitleBackgroundColorKey, encodeSyncString(it)) } - loadSubtitleOutlineColor()?.let { put(subtitleOutlineColorKey, encodeSyncString(it)) } - loadSubtitleOutlineEnabled()?.let { put(subtitleOutlineEnabledKey, encodeSyncBoolean(it)) } - loadSubtitleOutlineWidth()?.let { put(subtitleOutlineWidthKey, encodeSyncInt(it)) } - loadSubtitleBold()?.let { put(subtitleBoldKey, encodeSyncBoolean(it)) } - loadSubtitleFontSizeSp()?.let { put(subtitleFontSizeSpKey, encodeSyncInt(it)) } - loadSubtitleBottomOffset()?.let { put(subtitleBottomOffsetKey, encodeSyncInt(it)) } - loadSubtitleUseForcedSubtitles()?.let { put(subtitleUseForcedSubtitlesKey, encodeSyncBoolean(it)) } - loadSubtitleShowOnlyPreferredLanguages()?.let { put(subtitleShowOnlyPreferredLanguagesKey, encodeSyncBoolean(it)) } - loadAddonSubtitleStartupMode()?.let { put(addonSubtitleStartupModeKey, encodeSyncString(it)) } - loadStreamReuseLastLinkEnabled()?.let { put(streamReuseLastLinkEnabledKey, encodeSyncBoolean(it)) } - loadStreamReuseLastLinkCacheHours()?.let { put(streamReuseLastLinkCacheHoursKey, encodeSyncInt(it)) } - loadDecoderPriority()?.let { put(decoderPriorityKey, encodeSyncInt(it)) } - loadMapDV7ToHevc()?.let { put(mapDV7ToHevcKey, encodeSyncBoolean(it)) } - loadTunnelingEnabled()?.let { put(tunnelingEnabledKey, encodeSyncBoolean(it)) } - loadStreamAutoPlayMode()?.let { put(streamAutoPlayModeKey, encodeSyncString(it)) } - loadStreamAutoPlaySource()?.let { put(streamAutoPlaySourceKey, encodeSyncString(it)) } - loadStreamAutoPlaySelectedAddons()?.let { put(streamAutoPlaySelectedAddonsKey, encodeSyncStringSet(it)) } - loadStreamAutoPlaySelectedPlugins()?.let { put(streamAutoPlaySelectedPluginsKey, encodeSyncStringSet(it)) } - loadStreamAutoPlayRegex()?.let { put(streamAutoPlayRegexKey, encodeSyncString(it)) } - loadStreamAutoPlayTimeoutSeconds()?.let { put(streamAutoPlayTimeoutSecondsKey, encodeSyncInt(it)) } - loadSkipIntroEnabled()?.let { put(skipIntroEnabledKey, encodeSyncBoolean(it)) } - loadAnimeSkipEnabled()?.let { put(animeSkipEnabledKey, encodeSyncBoolean(it)) } - loadAnimeSkipClientId()?.let { put(animeSkipClientIdKey, encodeSyncString(it)) } - loadStreamAutoPlayNextEpisodeEnabled()?.let { put(streamAutoPlayNextEpisodeEnabledKey, encodeSyncBoolean(it)) } - loadStreamAutoPlayPreferBingeGroup()?.let { put(streamAutoPlayPreferBingeGroupKey, encodeSyncBoolean(it)) } - loadStreamAutoPlayReuseBingeGroup()?.let { put(streamAutoPlayReuseBingeGroupKey, encodeSyncBoolean(it)) } - loadNextEpisodeThresholdMode()?.let { put(nextEpisodeThresholdModeKey, encodeSyncString(it)) } - loadNextEpisodeThresholdPercent()?.let { put(nextEpisodeThresholdPercentKey, encodeSyncFloat(it)) } - loadNextEpisodeThresholdMinutesBeforeEnd()?.let { put(nextEpisodeThresholdMinutesBeforeEndKey, encodeSyncFloat(it)) } - loadUseLibass()?.let { put(useLibassKey, encodeSyncBoolean(it)) } - loadLibassRenderType()?.let { put(libassRenderTypeKey, encodeSyncString(it)) } - loadIosVideoOutputPreset()?.let { put(iosVideoOutputPresetKey, encodeSyncString(it)) } - loadIosToneMappingMode()?.let { put(iosToneMappingModeKey, encodeSyncString(it)) } - loadIosTargetPrimaries()?.let { put(iosTargetPrimariesKey, encodeSyncString(it)) } - loadIosTargetTransfer()?.let { put(iosTargetTransferKey, encodeSyncString(it)) } - loadIosHardwareDecoderMode()?.let { put(iosHardwareDecoderModeKey, encodeSyncString(it)) } - loadIosAudioOutputMode()?.let { put(iosAudioOutputModeKey, encodeSyncString(it)) } - loadIosExtendedDynamicRangeEnabled()?.let { put(iosExtendedDynamicRangeEnabledKey, encodeSyncBoolean(it)) } - loadIosTargetColorspaceHintEnabled()?.let { put(iosTargetColorspaceHintEnabledKey, encodeSyncBoolean(it)) } - loadIosHdrComputePeakEnabled()?.let { put(iosHdrComputePeakEnabledKey, encodeSyncBoolean(it)) } - loadIosDebandEnabled()?.let { put(iosDebandEnabledKey, encodeSyncBoolean(it)) } - loadIosInterpolationEnabled()?.let { put(iosInterpolationEnabledKey, encodeSyncBoolean(it)) } - loadIosBrightness()?.let { put(iosBrightnessKey, encodeSyncInt(it)) } - loadIosContrast()?.let { put(iosContrastKey, encodeSyncInt(it)) } - loadIosSaturation()?.let { put(iosSaturationKey, encodeSyncInt(it)) } - loadIosGamma()?.let { put(iosGammaKey, encodeSyncInt(it)) } - } - - actual fun replaceFromSyncPayload(payload: JsonObject) { - store.removeAll(syncKeys.map(::scoped)) - payload.decodeSyncBoolean(showLoadingOverlayKey)?.let(::saveShowLoadingOverlay) - payload.decodeSyncString(resizeModeKey)?.let(::saveResizeMode) - payload.decodeSyncBoolean(holdToSpeedEnabledKey)?.let(::saveHoldToSpeedEnabled) - payload.decodeSyncFloat(holdToSpeedValueKey)?.let(::saveHoldToSpeedValue) - payload.decodeSyncBoolean(externalPlayerEnabledKey)?.let(::saveExternalPlayerEnabled) - payload.decodeSyncBoolean(externalPlayerForwardSubtitlesKey)?.let(::saveExternalPlayerForwardSubtitles) - payload.decodeSyncString(externalPlayerIdKey)?.let(::saveExternalPlayerId) - payload.decodeSyncString(preferredAudioLanguageKey)?.let(::savePreferredAudioLanguage) - payload.decodeSyncString(secondaryPreferredAudioLanguageKey)?.let(::saveSecondaryPreferredAudioLanguage) - payload.decodeSyncString(preferredSubtitleLanguageKey)?.let(::savePreferredSubtitleLanguage) - payload.decodeSyncString(secondaryPreferredSubtitleLanguageKey)?.let(::saveSecondaryPreferredSubtitleLanguage) - payload.decodeSyncString(subtitleTextColorKey)?.let(::saveSubtitleTextColor) - payload.decodeSyncString(subtitleBackgroundColorKey)?.let(::saveSubtitleBackgroundColor) - payload.decodeSyncString(subtitleOutlineColorKey)?.let(::saveSubtitleOutlineColor) - payload.decodeSyncBoolean(subtitleOutlineEnabledKey)?.let(::saveSubtitleOutlineEnabled) - payload.decodeSyncInt(subtitleOutlineWidthKey)?.let(::saveSubtitleOutlineWidth) - payload.decodeSyncBoolean(subtitleBoldKey)?.let(::saveSubtitleBold) - payload.decodeSyncInt(subtitleFontSizeSpKey)?.let(::saveSubtitleFontSizeSp) - payload.decodeSyncInt(subtitleBottomOffsetKey)?.let(::saveSubtitleBottomOffset) - payload.decodeSyncBoolean(subtitleUseForcedSubtitlesKey)?.let(::saveSubtitleUseForcedSubtitles) - payload.decodeSyncBoolean(subtitleShowOnlyPreferredLanguagesKey)?.let(::saveSubtitleShowOnlyPreferredLanguages) - payload.decodeSyncString(addonSubtitleStartupModeKey)?.let(::saveAddonSubtitleStartupMode) - payload.decodeSyncBoolean(streamReuseLastLinkEnabledKey)?.let(::saveStreamReuseLastLinkEnabled) - payload.decodeSyncInt(streamReuseLastLinkCacheHoursKey)?.let(::saveStreamReuseLastLinkCacheHours) - payload.decodeSyncInt(decoderPriorityKey)?.let(::saveDecoderPriority) - payload.decodeSyncBoolean(mapDV7ToHevcKey)?.let(::saveMapDV7ToHevc) - payload.decodeSyncBoolean(tunnelingEnabledKey)?.let(::saveTunnelingEnabled) - payload.decodeSyncString(streamAutoPlayModeKey)?.let(::saveStreamAutoPlayMode) - payload.decodeSyncString(streamAutoPlaySourceKey)?.let(::saveStreamAutoPlaySource) - payload.decodeSyncStringSet(streamAutoPlaySelectedAddonsKey)?.let(::saveStreamAutoPlaySelectedAddons) - payload.decodeSyncStringSet(streamAutoPlaySelectedPluginsKey)?.let(::saveStreamAutoPlaySelectedPlugins) - payload.decodeSyncString(streamAutoPlayRegexKey)?.let(::saveStreamAutoPlayRegex) - payload.decodeSyncInt(streamAutoPlayTimeoutSecondsKey)?.let(::saveStreamAutoPlayTimeoutSeconds) - payload.decodeSyncBoolean(skipIntroEnabledKey)?.let(::saveSkipIntroEnabled) - payload.decodeSyncBoolean(animeSkipEnabledKey)?.let(::saveAnimeSkipEnabled) - payload.decodeSyncString(animeSkipClientIdKey)?.let(::saveAnimeSkipClientId) - payload.decodeSyncString(introDbApiKeyKey)?.let(::saveIntroDbApiKey) - payload.decodeSyncBoolean(introSubmitEnabledKey)?.let(::saveIntroSubmitEnabled) - payload.decodeSyncBoolean(streamAutoPlayNextEpisodeEnabledKey)?.let(::saveStreamAutoPlayNextEpisodeEnabled) - payload.decodeSyncBoolean(streamAutoPlayPreferBingeGroupKey)?.let(::saveStreamAutoPlayPreferBingeGroup) - payload.decodeSyncBoolean(streamAutoPlayReuseBingeGroupKey)?.let(::saveStreamAutoPlayReuseBingeGroup) - payload.decodeSyncString(nextEpisodeThresholdModeKey)?.let(::saveNextEpisodeThresholdMode) - payload.decodeSyncFloat(nextEpisodeThresholdPercentKey)?.let(::saveNextEpisodeThresholdPercent) - payload.decodeSyncFloat(nextEpisodeThresholdMinutesBeforeEndKey)?.let(::saveNextEpisodeThresholdMinutesBeforeEnd) - payload.decodeSyncBoolean(useLibassKey)?.let(::saveUseLibass) - payload.decodeSyncString(libassRenderTypeKey)?.let(::saveLibassRenderType) - payload.decodeSyncString(iosVideoOutputPresetKey)?.let(::saveIosVideoOutputPreset) - payload.decodeSyncString(iosToneMappingModeKey)?.let(::saveIosToneMappingMode) - payload.decodeSyncString(iosTargetPrimariesKey)?.let(::saveIosTargetPrimaries) - payload.decodeSyncString(iosTargetTransferKey)?.let(::saveIosTargetTransfer) - payload.decodeSyncString(iosHardwareDecoderModeKey)?.let(::saveIosHardwareDecoderMode) - payload.decodeSyncString(iosAudioOutputModeKey)?.let(::saveIosAudioOutputMode) - payload.decodeSyncBoolean(iosExtendedDynamicRangeEnabledKey)?.let(::saveIosExtendedDynamicRangeEnabled) - payload.decodeSyncBoolean(iosTargetColorspaceHintEnabledKey)?.let(::saveIosTargetColorspaceHintEnabled) - payload.decodeSyncBoolean(iosHdrComputePeakEnabledKey)?.let(::saveIosHdrComputePeakEnabled) - payload.decodeSyncBoolean(iosDebandEnabledKey)?.let(::saveIosDebandEnabled) - payload.decodeSyncBoolean(iosInterpolationEnabledKey)?.let(::saveIosInterpolationEnabled) - payload.decodeSyncInt(iosBrightnessKey)?.let(::saveIosBrightness) - payload.decodeSyncInt(iosContrastKey)?.let(::saveIosContrast) - payload.decodeSyncInt(iosSaturationKey)?.let(::saveIosSaturation) - payload.decodeSyncInt(iosGammaKey)?.let(::saveIosGamma) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.desktop.kt deleted file mode 100644 index d35d9894..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.desktop.kt +++ /dev/null @@ -1,94 +0,0 @@ -package com.nuvio.app.features.player - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey - -internal actual object PlayerTrackPreferenceStorage { - private const val subtitleTypeKey = "subtitle_type" - private const val subtitleLanguageKey = "subtitle_language" - private const val subtitleNameKey = "subtitle_name" - private const val subtitleTrackIdKey = "subtitle_track_id" - private const val addonSubtitleIdKey = "addon_subtitle_id" - private const val addonSubtitleUrlKey = "addon_subtitle_url" - private const val addonSubtitleAddonNameKey = "addon_subtitle_addon_name" - private const val audioLanguageKey = "audio_language" - private const val audioNameKey = "audio_name" - private const val audioTrackIdKey = "audio_track_id" - private const val subtitleDelayMsKey = "subtitle_delay_ms" - private val store = DesktopStorage.store("nuvio_player_track_preferences") - - actual fun load(contentId: String): PersistedPlayerTrackPreference? { - val id = contentId.normalizedStorageId() ?: return null - val preference = PersistedPlayerTrackPreference( - subtitleType = loadString(subtitleTypeKey, id), - subtitleLanguage = loadString(subtitleLanguageKey, id), - subtitleName = loadString(subtitleNameKey, id), - subtitleTrackId = loadString(subtitleTrackIdKey, id), - addonSubtitleId = loadString(addonSubtitleIdKey, id), - addonSubtitleUrl = loadString(addonSubtitleUrlKey, id), - addonSubtitleAddonName = loadString(addonSubtitleAddonNameKey, id), - audioLanguage = loadString(audioLanguageKey, id), - audioName = loadString(audioNameKey, id), - audioTrackId = loadString(audioTrackIdKey, id), - ) - return preference.takeIf { - listOf( - it.subtitleType, - it.subtitleLanguage, - it.subtitleName, - it.subtitleTrackId, - it.addonSubtitleId, - it.addonSubtitleUrl, - it.addonSubtitleAddonName, - it.audioLanguage, - it.audioName, - it.audioTrackId, - ).any { value -> !value.isNullOrBlank() } - } - } - - actual fun save(contentId: String, preference: PersistedPlayerTrackPreference) { - val id = contentId.normalizedStorageId() ?: return - putOptionalString(subtitleTypeKey, id, preference.subtitleType) - putOptionalString(subtitleLanguageKey, id, preference.subtitleLanguage) - putOptionalString(subtitleNameKey, id, preference.subtitleName) - putOptionalString(subtitleTrackIdKey, id, preference.subtitleTrackId) - putOptionalString(addonSubtitleIdKey, id, preference.addonSubtitleId) - putOptionalString(addonSubtitleUrlKey, id, preference.addonSubtitleUrl) - putOptionalString(addonSubtitleAddonNameKey, id, preference.addonSubtitleAddonName) - putOptionalString(audioLanguageKey, id, preference.audioLanguage) - putOptionalString(audioNameKey, id, preference.audioName) - putOptionalString(audioTrackIdKey, id, preference.audioTrackId) - } - - actual fun loadSubtitleDelayMs(videoId: String): Int? { - val id = videoId.normalizedStorageId() ?: return null - return store.getInt(scopedKey(subtitleDelayMsKey, id)) - } - - actual fun saveSubtitleDelayMs(videoId: String, delayMs: Int) { - val id = videoId.normalizedStorageId() ?: return - store.putInt( - scopedKey(subtitleDelayMsKey, id), - delayMs.coerceIn(SUBTITLE_DELAY_MIN_MS, SUBTITLE_DELAY_MAX_MS), - ) - } - - private fun loadString(field: String, contentId: String): String? = - store.getString(scopedKey(field, contentId))?.takeIf { it.isNotBlank() } - - private fun putOptionalString(field: String, contentId: String, value: String?) { - val key = scopedKey(field, contentId) - if (value.isNullOrBlank()) { - store.remove(key) - } else { - store.putString(key, value) - } - } - - private fun scopedKey(field: String, contentId: String): String = - ProfileScopedKey.of("$field|$contentId") - - private fun String.normalizedStorageId(): String? = - trim().takeIf { it.isNotBlank() } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopAppFullscreen.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopAppFullscreen.kt deleted file mode 100644 index a47ecaaf..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopAppFullscreen.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.nuvio.app.features.player.desktop - -import java.awt.KeyEventDispatcher -import java.awt.KeyboardFocusManager -import java.awt.Window -import java.awt.event.KeyEvent -import javax.swing.SwingUtilities - -private object DesktopAppFullscreen { - private var toggleHandler: ((Window?) -> Unit)? = null - - fun setToggleHandler(handler: ((Window?) -> Unit)?): () -> Unit { - toggleHandler = handler - return { - if (toggleHandler === handler) { - toggleHandler = null - } - } - } - - fun toggle(window: Window? = null) { - val handler = toggleHandler ?: return - if (SwingUtilities.isEventDispatchThread()) { - handler(window) - } else { - SwingUtilities.invokeLater { handler(window) } - } - } -} - -internal fun registerDesktopAppFullscreenToggle(handler: (Window?) -> Unit): () -> Unit = - DesktopAppFullscreen.setToggleHandler(handler) - -internal fun toggleDesktopAppFullscreen(window: Window? = null) { - DesktopAppFullscreen.toggle(window) -} - -internal fun installDesktopAppFullscreenShortcuts(window: Window): () -> Unit { - val dispatcher = KeyEventDispatcher { event -> - if (!event.isDesktopAppFullscreenShortcut()) return@KeyEventDispatcher false - toggleDesktopAppFullscreen(window) - true - } - KeyboardFocusManager.getCurrentKeyboardFocusManager().addKeyEventDispatcher(dispatcher) - return { - KeyboardFocusManager.getCurrentKeyboardFocusManager().removeKeyEventDispatcher(dispatcher) - } -} - -private fun KeyEvent.isDesktopAppFullscreenShortcut(): Boolean { - if (id != KeyEvent.KEY_PRESSED) return false - if (keyCode == KeyEvent.VK_F11) return true - if (keyCode != KeyEvent.VK_F) return false - val modifiers = modifiersEx - val hasMacFullscreenModifiers = - modifiers and KeyEvent.META_DOWN_MASK != 0 && - modifiers and KeyEvent.CTRL_DOWN_MASK != 0 && - modifiers and KeyEvent.ALT_DOWN_MASK == 0 - return hasMacFullscreenModifiers -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopHostOs.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopHostOs.kt deleted file mode 100644 index fb63eae7..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopHostOs.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.nuvio.app.features.player.desktop - -import java.util.Locale - -internal enum class DesktopHostOs { - MACOS, - WINDOWS, - LINUX, - UNKNOWN; - - companion object { - val current: DesktopHostOs by lazy { - val osName = System.getProperty("os.name").orEmpty().lowercase(Locale.ROOT) - when { - osName.contains("mac") -> MACOS - osName.contains("win") -> WINDOWS - osName.contains("linux") -> LINUX - else -> UNKNOWN - } - } - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopPlayerLaunchShield.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopPlayerLaunchShield.kt deleted file mode 100644 index 1da37699..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopPlayerLaunchShield.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.nuvio.app.features.player.desktop - -import java.awt.Color -import java.awt.KeyboardFocusManager -import java.awt.Rectangle -import java.awt.Window -import javax.swing.JWindow -import javax.swing.RootPaneContainer -import javax.swing.SwingUtilities -import javax.swing.Timer - -internal object DesktopPlayerLaunchShield { - private var shieldWindow: JWindow? = null - private var hideTimer: Timer? = null - - fun showForActiveWindow() { - if (DesktopHostOs.current != DesktopHostOs.WINDOWS) return - SwingUtilities.invokeLater { - val owner = activeOwnerWindow() ?: return@invokeLater - val bounds = ownerContentBounds(owner) ?: return@invokeLater - hideTimer?.stop() - hideTimer = null - - val shield = shieldWindow?.takeIf { it.owner === owner } ?: JWindow(owner).also { window -> - window.background = Color.BLACK - window.contentPane.background = Color.BLACK - window.focusableWindowState = false - window.setType(Window.Type.POPUP) - shieldWindow = window - } - shield.bounds = bounds - if (!shield.isVisible) { - shield.isVisible = true - } - shield.toFront() - hideAfter(3_000) - } - } - - fun hideAfter(delayMs: Int = 48) { - SwingUtilities.invokeLater { - hideTimer?.stop() - hideTimer = Timer(delayMs) { - hideNow() - }.apply { - isRepeats = false - start() - } - } - } - - fun hide() { - SwingUtilities.invokeLater { - hideTimer?.stop() - hideTimer = null - hideNow() - } - } - - private fun hideNow() { - val shield = shieldWindow ?: return - if (shield.isVisible) { - shield.isVisible = false - } - } - - private fun activeOwnerWindow(): Window? { - val active = KeyboardFocusManager.getCurrentKeyboardFocusManager().activeWindow - if (active?.isShowing == true) return active - return Window.getWindows() - .filter { it.isShowing && it !is JWindow } - .firstOrNull() - } - - private fun ownerContentBounds(owner: Window): Rectangle? { - val content = (owner as? RootPaneContainer)?.contentPane - return runCatching { - if (content != null && content.isShowing) { - val location = content.locationOnScreen - Rectangle(location.x, location.y, content.width, content.height) - } else { - val location = owner.locationOnScreen - Rectangle(location.x, location.y, owner.width, owner.height) - } - }.getOrNull() - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopWindowChrome.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopWindowChrome.kt deleted file mode 100644 index 34296259..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopWindowChrome.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.nuvio.app.features.player.desktop - -import java.awt.Window - -private const val NuvioWindowBackgroundRgb = 0x0D0D0D -private const val NuvioWindowTextRgb = 0xF5F7F8 - -internal fun applyNativeDesktopWindowChrome(window: Window) { - if (DesktopHostOs.current != DesktopHostOs.WINDOWS || !window.isDisplayable) return - - runCatching { - val hwnd = AwtNativeViewResolver.resolveNativeViewPointer(window) - NativePlayerBridge.applyWindowChrome( - windowHwnd = hwnd, - darkMode = true, - captionColorRgb = NuvioWindowBackgroundRgb, - borderColorRgb = NuvioWindowBackgroundRgb, - textColorRgb = NuvioWindowTextRgb, - ) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/MacosAwtViewResolver.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/MacosAwtViewResolver.kt deleted file mode 100644 index aea9d4b7..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/MacosAwtViewResolver.kt +++ /dev/null @@ -1,82 +0,0 @@ -package com.nuvio.app.features.player.desktop - -import java.awt.Component -import java.lang.reflect.Field -import java.lang.reflect.Method - -internal object AwtNativeViewResolver { - fun resolveNativeViewPointer(component: Component): Long = - when (DesktopHostOs.current) { - DesktopHostOs.MACOS -> MacosAwtViewResolver.resolveNativeViewPointer(component) - DesktopHostOs.WINDOWS -> WindowsAwtViewResolver.resolveNativeViewPointer(component) - else -> error("Native desktop playback is not implemented for ${DesktopHostOs.current}.") - } -} - -private object MacosAwtViewResolver { - private val componentPeerField: Field by lazy { - Component::class.java.getDeclaredField("peer").apply { isAccessible = true } - } - - fun resolveNativeViewPointer(component: Component): Long { - val peer = componentPeerField.get(component) - ?: error("AWT component peer is not ready for native playback.") - - val platformWindow = invokeObject(peer, "getPlatformWindow") - val contentView = invokeObject(platformWindow, "getContentView") - val pointer = invokeLong(contentView, "getAWTView") - if (pointer == 0L) { - error("macOS AWT view pointer was zero.") - } - return pointer - } - - private fun findMethod(type: Class<*>, name: String): Method { - var current: Class<*>? = type - while (current != null) { - runCatching { - return current.getDeclaredMethod(name).apply { isAccessible = true } - } - current = current.superclass - } - error("Method $name was not found on ${type.name}.") - } - - private fun invokeObject(target: Any, methodName: String): Any = - findMethod(target.javaClass, methodName).invoke(target) - ?: error("$methodName returned null.") - - private fun invokeLong(target: Any, methodName: String): Long = - (findMethod(target.javaClass, methodName).invoke(target) as Number).toLong() -} - -private object WindowsAwtViewResolver { - private val componentPeerField: Field by lazy { - Component::class.java.getDeclaredField("peer").apply { isAccessible = true } - } - - fun resolveNativeViewPointer(component: Component): Long { - val peer = componentPeerField.get(component) - ?: error("AWT component peer is not ready for native playback.") - - val pointer = invokeLong(peer, "getHWnd") - if (pointer == 0L) { - error("Windows AWT HWND pointer was zero.") - } - return pointer - } - - private fun findMethod(type: Class<*>, name: String): Method { - var current: Class<*>? = type - while (current != null) { - runCatching { - return current.getDeclaredMethod(name).apply { isAccessible = true } - } - current = current.superclass - } - error("Method $name was not found on ${type.name}.") - } - - private fun invokeLong(target: Any, methodName: String): Long = - (findMethod(target.javaClass, methodName).invoke(target) as Number).toLong() -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerBridge.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerBridge.kt deleted file mode 100644 index 149078b7..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerBridge.kt +++ /dev/null @@ -1,286 +0,0 @@ -package com.nuvio.app.features.player.desktop - -import java.io.File -import java.nio.file.Files -import java.util.concurrent.atomic.AtomicBoolean - -internal fun interface NativePlayerEventSink { - fun onPlayerEvent(type: String, value: Double) -} - -internal object NativePlayerBridge { - private val preloadStarted = AtomicBoolean(false) - - init { - loadNativeLibrary() - } - - external fun create( - hostViewPtr: Long, - sourceUrl: String, - headerLines: Array, - playWhenReady: Boolean, - initialPositionMs: Long, - controlsPageUrl: String, - eventSink: NativePlayerEventSink, - ): Long - - external fun dispose(handle: Long) - external fun updateControls(handle: Long, controlsJson: String) - external fun setPaused(handle: Long, paused: Boolean) - external fun seekTo(handle: Long, positionMs: Long) - external fun seekBy(handle: Long, offsetMs: Long) - external fun setSpeed(handle: Long, speed: Float) - external fun setResizeMode(handle: Long, mode: Int) - external fun durationMs(handle: Long): Long - external fun positionMs(handle: Long): Long - external fun bufferedPositionMs(handle: Long): Long - external fun isLoading(handle: Long): Boolean - external fun isEnded(handle: Long): Boolean - external fun isPaused(handle: Long): Boolean - external fun speed(handle: Long): Float - external fun audioTracksJson(handle: Long): String - external fun subtitleTracksJson(handle: Long): String - external fun selectAudioTrack(handle: Long, trackId: Int) - external fun selectSubtitleTrack(handle: Long, trackId: Int) - external fun addSubtitleUrl(handle: Long, url: String) - external fun clearExternalSubtitles(handle: Long) - external fun clearExternalSubtitlesAndSelect(handle: Long, trackId: Int) - external fun applyWindowChrome( - windowHwnd: Long, - darkMode: Boolean, - captionColorRgb: Int, - borderColorRgb: Int, - textColorRgb: Int, - ) - - external fun setSubtitleDelayMs(handle: Long, delayMs: Int) - external fun applySubtitleStyle( - handle: Long, - textColor: String, - backgroundColor: String, - outlineColor: String, - outlineSize: Float, - bold: Boolean, - fontSize: Float, - subPos: Int, - ) - external fun warmupWebView2(controlsPageUrl: String): Boolean - external fun shutdownWebView2Warmup() - - val controlsPageUrl: String by lazy { controlsPageAssets.url } - private val controlsPageAssets: ControlsPageAssets by lazy { exportControlsPageAssets() } - - fun preloadAsync() { - if (!preloadStarted.compareAndSet(false, true)) return - Thread { - val controlsPage = runCatching { controlsPageAssets } - .getOrNull() - ?: return@Thread - if (DesktopHostOs.current == DesktopHostOs.WINDOWS) { - runCatching { warmupWebView2(controlsPage.url) } - } - }.apply { - name = "nuvio-native-player-preload" - isDaemon = true - start() - } - if (DesktopHostOs.current == DesktopHostOs.WINDOWS) { - Runtime.getRuntime().addShutdownHook( - Thread { - runCatching { shutdownWebView2Warmup() } - }.apply { - name = "nuvio-webview2-warmup-shutdown" - } - ) - } - } - - private fun loadNativeLibrary() { - val platform = DesktopHostOs.current - require(platform == DesktopHostOs.MACOS || platform == DesktopHostOs.WINDOWS) { - "Native desktop playback is not implemented for $platform yet." - } - - val libraryName = nativeLibraryName(platform) - val platformDir = nativeDirectoryName(platform) - findLocalBuildLibrary(platformDir, libraryName)?.let { localLibrary -> - copyLocalRuntimeResources(platformDir, localLibrary.parentFile) - System.load(localLibrary.absolutePath) - return - } - - val resource = "/native/$platformDir/$libraryName" - val input = NativePlayerBridge::class.java.getResourceAsStream(resource) - ?: error("Missing bundled native player bridge: $resource") - val dir = File(System.getProperty("java.io.tmpdir"), "native-player-bridge").apply { mkdirs() } - val suffix = libraryName.substringAfter("player_bridge", ".dylib") - val file = Files.createTempFile(dir.toPath(), "player-bridge-", suffix).toFile() - file.deleteOnExit() - extractBundledRuntimeResources(platformDir, dir) - input.use { source -> - file.outputStream().use { target -> source.copyTo(target) } - } - System.load(file.absolutePath) - } - - private fun extractBundledRuntimeResources(platformDir: String, dir: File) { - val runtimeNames = bundledRuntimeResourceNames(platformDir) - runtimeNames.forEach { name -> - val resource = "/native/$platformDir/$name" - val input = NativePlayerBridge::class.java.getResourceAsStream(resource) ?: return@forEach - val target = dir.resolve(name) - input.use { source -> - target.outputStream().use { output -> source.copyTo(output) } - } - target.deleteOnExit() - } - } - - private fun bundledRuntimeResourceNames(platformDir: String): List { - val indexResource = "/native/$platformDir/runtime-files.txt" - val indexed = NativePlayerBridge::class.java.getResourceAsStream(indexResource) - ?.bufferedReader() - ?.useLines { lines -> - lines.map(String::trim) - .filter { it.isNotEmpty() && !it.startsWith("#") } - .toList() - } - .orEmpty() - if (indexed.isNotEmpty()) return indexed - - return when (platformDir) { - "windows" -> listOf("libmpv-2.dll") - else -> emptyList() - } - } - - private fun findLocalBuildLibrary(platformDir: String, libraryName: String): File? { - val candidates = listOf( - File("composeApp/build/native/$platformDir/$libraryName"), - File("build/native/$platformDir/$libraryName"), - ) - return candidates.firstOrNull { it.exists() } - } - - private fun copyLocalRuntimeResources(platformDir: String, targetDir: File) { - val runtimeDirs = listOf( - File("composeApp/build/native/$platformDir-runtime"), - File("build/native/$platformDir-runtime"), - ) - runtimeDirs.firstOrNull(File::isDirectory) - ?.listFiles { file -> file.isFile } - ?.forEach { runtimeFile -> - val target = targetDir.resolve(runtimeFile.name) - if (runtimeFile.absolutePath != target.absolutePath) { - runCatching { runtimeFile.copyTo(target, overwrite = true) } - } - } - } - - private fun nativeDirectoryName(platform: DesktopHostOs): String = - when (platform) { - DesktopHostOs.MACOS -> "macos" - DesktopHostOs.WINDOWS -> "windows" - DesktopHostOs.LINUX -> "linux" - DesktopHostOs.UNKNOWN -> "unknown" - } - - private fun nativeLibraryName(platform: DesktopHostOs): String = - when (platform) { - DesktopHostOs.MACOS -> "libplayer_bridge.dylib" - DesktopHostOs.WINDOWS -> "player_bridge.dll" - DesktopHostOs.LINUX -> "libplayer_bridge.so" - DesktopHostOs.UNKNOWN -> "player_bridge" - } - - private fun exportControlsPageAssets(): ControlsPageAssets { - val root = File(System.getProperty("java.io.tmpdir"), "nuvio-player-ui").apply { mkdirs() } - val fontsDir = root.resolve("fonts").apply { mkdirs() } - val htmlFile = root.resolve("controls.html") - writeTextIfChanged( - target = htmlFile, - text = readTextResource("/player-ui/controls.html"), - ) - writeTextIfChanged( - target = root.resolve("controls.css"), - text = readTextResource("/player-ui/controls.css") - .replace("/* __NUVIO_PLAYER_FONT_FACES__ */", nativePlayerFontFaces()), - ) - copyResourceIfChanged( - resource = "/player-ui/controls.js", - target = root.resolve("controls.js"), - ) - copyResourceIfChanged( - resource = "/composeResources/nuvio.composeapp.generated.resources/font/jetbrains_sans_regular.ttf", - target = fontsDir.resolve("jetbrains_sans_regular.ttf"), - ) - copyResourceIfChanged( - resource = "/composeResources/nuvio.composeapp.generated.resources/font/jetbrains_sans_semibold.ttf", - target = fontsDir.resolve("jetbrains_sans_semibold.ttf"), - ) - copyResourceIfChanged( - resource = "/composeResources/nuvio.composeapp.generated.resources/font/jetbrains_sans_bold.ttf", - target = fontsDir.resolve("jetbrains_sans_bold.ttf"), - ) - return ControlsPageAssets( - url = htmlFile.toURI().toASCIIString(), - ) - } - - private fun nativePlayerFontFaces(): String = - """ - @font-face { - font-family: "Nuvio JetBrains Sans"; - src: url("fonts/jetbrains_sans_regular.ttf") format("truetype"); - font-weight: 400; - font-style: normal; - font-display: block; - } - @font-face { - font-family: "Nuvio JetBrains Sans"; - src: url("fonts/jetbrains_sans_semibold.ttf") format("truetype"); - font-weight: 600; - font-style: normal; - font-display: block; - } - @font-face { - font-family: "Nuvio JetBrains Sans"; - src: url("fonts/jetbrains_sans_bold.ttf") format("truetype"); - font-weight: 700 900; - font-style: normal; - font-display: block; - } - """.trimIndent() - - private fun readTextResource(resource: String): String = - NativePlayerBridge::class.java.getResourceAsStream(resource) - ?.bufferedReader(Charsets.UTF_8) - ?.use { it.readText() } - ?: error("Missing native player controls resource: $resource") - - private fun writeTextIfChanged(target: File, text: String) { - val bytes = text.toByteArray(Charsets.UTF_8) - if (target.exists() && target.readBytes().contentEquals(bytes)) return - target.writeBytes(bytes) - } - - private fun copyResourceIfChanged(resource: String, target: File) { - val bytes = NativePlayerBridge::class.java.getResourceAsStream(resource) - ?.use { it.readBytes() } - ?: error("Missing native player controls resource: $resource") - if (target.exists() && target.readBytes().contentEquals(bytes)) return - Files.createDirectories(target.parentFile.toPath()) - target.writeBytes(bytes) - } - - private data class ControlsPageAssets( - val url: String, - ) -} - -internal fun preloadNativePlayerBridgeAsync() { - if (DesktopHostOs.current == DesktopHostOs.MACOS || DesktopHostOs.current == DesktopHostOs.WINDOWS) { - NativePlayerBridge.preloadAsync() - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerController.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerController.kt deleted file mode 100644 index 9bf4f3cc..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerController.kt +++ /dev/null @@ -1,953 +0,0 @@ -package com.nuvio.app.features.player.desktop - -import androidx.compose.ui.graphics.Color -import com.nuvio.app.features.player.PlayerControlAddonSubtitleItem -import com.nuvio.app.features.player.PlayerControlEpisodeItem -import com.nuvio.app.features.player.PlayerControlFilterItem -import com.nuvio.app.features.player.PlayerControlSeasonItem -import com.nuvio.app.features.player.PlayerControlSourceItem -import com.nuvio.app.features.player.PlayerControlSubtitleCueItem -import com.nuvio.app.features.player.AudioTrack -import com.nuvio.app.features.player.ParentalWarning -import com.nuvio.app.features.player.PlayerControlsAction -import com.nuvio.app.features.player.PlayerControlsState -import com.nuvio.app.features.player.PlayerEngineController -import com.nuvio.app.features.player.PlayerPlaybackSnapshot -import com.nuvio.app.features.player.PlayerResizeMode -import com.nuvio.app.features.player.SUBTITLE_DELAY_MAX_MS -import com.nuvio.app.features.player.SUBTITLE_DELAY_MIN_MS -import com.nuvio.app.features.player.SubtitleColorSwatches -import com.nuvio.app.features.player.SubtitleStyleState -import com.nuvio.app.features.player.SubtitleTrack -import com.nuvio.app.features.player.inferForcedSubtitleTrack -import com.nuvio.app.features.player.toStorageHexString -import kotlinx.serialization.Serializable -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.json.Json -import javax.swing.SwingUtilities -import kotlin.concurrent.Volatile - -internal class NativePlayerController( - private val host: NativePlayerHost, -) : PlayerEngineController { - private companion object { - val json = Json { ignoreUnknownKeys = true } - } - - @Volatile - private var handle: Long = 0L - private var pendingSource: PendingSource? = null - private var controlsState = PlayerControlsState() - private var lastSentControlsStructureKey: PlayerControlsState? = null - private var onAction: (PlayerControlsAction) -> Boolean = { false } - private var onEvent: (String, Double) -> Boolean = { _, _ -> false } - private var onScrubChange: (Long) -> Boolean = { false } - private var onScrubFinished: (Long) -> Boolean = { false } - private val eventSink = NativePlayerEventSink { type, value -> - SwingUtilities.invokeLater { - handlePlayerEvent(type, value) - } - } - - fun attach( - sourceUrl: String, - sourceHeaders: Map, - playWhenReady: Boolean, - initialPositionMs: Long, - onError: (String?) -> Unit, - ) { - val pending = PendingSource( - sourceUrl = sourceUrl, - headerLines = sourceHeaders.toHeaderLines(), - playWhenReady = playWhenReady, - initialPositionMs = initialPositionMs.coerceAtLeast(0L), - onError = onError, - ) - pendingSource = pending - host.onPeerReady = { attachPending() } - if (host.isDisplayable) { - attachPending() - } - } - - private fun attachPending() { - val pending = pendingSource ?: return - SwingUtilities.invokeLater { - if (!host.isDisplayable) { - return@invokeLater - } - disposePlayerHandle() - runCatching { - val hostViewPtr = AwtNativeViewResolver.resolveNativeViewPointer(host) - handle = NativePlayerBridge.create( - hostViewPtr = hostViewPtr, - sourceUrl = pending.sourceUrl, - headerLines = pending.headerLines.toTypedArray(), - playWhenReady = pending.playWhenReady, - initialPositionMs = pending.initialPositionMs, - controlsPageUrl = NativePlayerBridge.controlsPageUrl, - eventSink = eventSink, - ) - if (handle == 0L) error("Native player did not return a handle.") - updateControls(controlsState) - }.onFailure { error -> - pending.onError(error.message) - } - } - } - - fun setControlCallbacks( - onAction: (PlayerControlsAction) -> Boolean, - onEvent: (String, Double) -> Boolean, - onScrubChange: (Long) -> Boolean, - onScrubFinished: (Long) -> Boolean, - ) { - this.onAction = onAction - this.onEvent = onEvent - this.onScrubChange = onScrubChange - this.onScrubFinished = onScrubFinished - } - - fun updateControls(state: PlayerControlsState) { - controlsState = state - val currentHandle = handle - val structureKey = state.nativeControlsStructureKey() - val current = currentHandle.takeIf { it != 0L } ?: return - if (structureKey == lastSentControlsStructureKey) return - lastSentControlsStructureKey = structureKey - NativePlayerBridge.updateControls(current, state.toControlsJson()) - } - - fun setResizeMode(mode: PlayerResizeMode) { - handle.takeIf { it != 0L }?.let { current -> - NativePlayerBridge.setResizeMode( - handle = current, - mode = when (mode) { - PlayerResizeMode.Fit -> 0 - PlayerResizeMode.Fill -> 1 - PlayerResizeMode.Zoom -> 2 - }, - ) - } - } - - private fun handlePlayerEvent(type: String, value: Double) { - when (type) { - "scrubChange" -> { - if (!onScrubChange(value.toLong())) { - updateLocalProgress(value.toLong()) - } - } - "scrubFinish" -> { - val scrubHandled = onScrubFinished(value.toLong()) - if (!scrubHandled) { - seekTo(value.toLong()) - } - } - "toggleFullscreen" -> toggleDesktopAppFullscreen(SwingUtilities.getWindowAncestor(host)) - else -> { - val eventHandled = onEvent(type, value) - if (eventHandled) return - val action = type.toPlayerControlsAction() - if (action == null) return - val actionHandled = onAction(action) - if (!actionHandled) { - handleFallbackAction(action) - } - } - } - } - - private fun updateLocalProgress(positionMs: Long) { - controlsState = controlsState.copy(positionMs = positionMs) - updateControls(controlsState) - } - - private fun handleFallbackAction(action: PlayerControlsAction) { - when (action) { - PlayerControlsAction.TogglePlayback, - PlayerControlsAction.KeyboardTogglePlayback -> { - val current = handle - if (current == 0L) return - val isEnded = NativePlayerBridge.isEnded(current) - val isPaused = NativePlayerBridge.isPaused(current) - if (isEnded) { - NativePlayerBridge.seekTo(current, 0L) - NativePlayerBridge.setPaused(current, false) - } else { - NativePlayerBridge.setPaused(current, !isPaused) - } - } - PlayerControlsAction.SeekBack, - PlayerControlsAction.KeyboardSeekBack -> fallbackSeekBy(-10_000L) - PlayerControlsAction.SeekForward, - PlayerControlsAction.KeyboardSeekForward -> fallbackSeekBy(10_000L) - PlayerControlsAction.Speed -> cycleFallbackSpeed() - else -> Unit - } - } - - private fun fallbackSeekBy(offsetMs: Long) { - val current = handle - if (current != 0L) { - NativePlayerBridge.seekBy(current, offsetMs) - } - } - - private fun cycleFallbackSpeed() { - val current = handle - if (current == 0L) return - val speeds = listOf(1f, 1.25f, 1.5f, 2f) - val currentSpeed = NativePlayerBridge.speed(current) - val next = speeds.firstOrNull { it > currentSpeed + 0.01f } ?: speeds.first() - NativePlayerBridge.setSpeed(current, next) - } - - fun snapshot(): PlayerPlaybackSnapshot { - val current = handle - if (current == 0L) return PlayerPlaybackSnapshot(isLoading = true) - return runCatching { - val isLoading = NativePlayerBridge.isLoading(current) - val isEnded = NativePlayerBridge.isEnded(current) - PlayerPlaybackSnapshot( - isLoading = isLoading, - isPlaying = !NativePlayerBridge.isPaused(current) && !isLoading && !isEnded, - isEnded = isEnded, - durationMs = NativePlayerBridge.durationMs(current), - positionMs = NativePlayerBridge.positionMs(current), - bufferedPositionMs = NativePlayerBridge.bufferedPositionMs(current), - playbackSpeed = NativePlayerBridge.speed(current), - ) - }.getOrDefault(PlayerPlaybackSnapshot(isLoading = true)) - } - - fun dispose() { - disposePlayerHandle() - } - - private fun disposePlayerHandle() { - val current = handle - handle = 0L - lastSentControlsStructureKey = null - if (current != 0L) { - runCatching { NativePlayerBridge.dispose(current) } - } - } - - override fun play() { - handle.takeIf { it != 0L }?.let { NativePlayerBridge.setPaused(it, false) } - } - - override fun pause() { - handle.takeIf { it != 0L }?.let { NativePlayerBridge.setPaused(it, true) } - } - - override fun seekTo(positionMs: Long) { - handle.takeIf { it != 0L }?.let { NativePlayerBridge.seekTo(it, positionMs) } - } - - override fun seekBy(offsetMs: Long) { - handle.takeIf { it != 0L }?.let { NativePlayerBridge.seekBy(it, offsetMs) } - } - - override fun retry() { - val pending = pendingSource ?: return - attach( - sourceUrl = pending.sourceUrl, - sourceHeaders = pending.headerLines.toHeaderMap(), - playWhenReady = pending.playWhenReady, - initialPositionMs = pending.initialPositionMs, - onError = pending.onError, - ) - } - - override fun setPlaybackSpeed(speed: Float) { - handle.takeIf { it != 0L }?.let { NativePlayerBridge.setSpeed(it, speed) } - } - - override fun getAudioTracks(): List = - decodeTracks { NativePlayerBridge.audioTracksJson(it) }.map { track -> - AudioTrack( - index = track.index, - id = track.id, - label = track.label, - language = track.language.takeUnless(String::isBlank), - isSelected = track.selected, - ) - } - - override fun getSubtitleTracks(): List = - decodeTracks { NativePlayerBridge.subtitleTracksJson(it) }.map { track -> - SubtitleTrack( - index = track.index, - id = track.id, - label = track.label, - language = track.language.takeUnless(String::isBlank), - isSelected = track.selected, - isForced = track.forced || inferForcedSubtitleTrack( - label = track.label, - language = track.language, - trackId = track.id, - ), - ) - } - - override fun selectAudioTrack(index: Int) { - val current = handle.takeIf { it != 0L } ?: return - val trackId = resolveTrackId(index, decodeTracks { NativePlayerBridge.audioTracksJson(it) }) ?: return - NativePlayerBridge.selectAudioTrack(current, trackId) - } - - override fun selectSubtitleTrack(index: Int) { - val current = handle.takeIf { it != 0L } ?: return - if (index < 0) { - NativePlayerBridge.selectSubtitleTrack(current, -1) - return - } - val trackId = resolveTrackId(index, decodeTracks { NativePlayerBridge.subtitleTracksJson(it) }) ?: return - NativePlayerBridge.selectSubtitleTrack(current, trackId) - } - - override fun setSubtitleUri(url: String) { - handle.takeIf { it != 0L }?.let { NativePlayerBridge.addSubtitleUrl(it, url) } - } - - override fun clearExternalSubtitle() { - handle.takeIf { it != 0L }?.let(NativePlayerBridge::clearExternalSubtitles) - } - - override fun clearExternalSubtitleAndSelect(trackIndex: Int) { - val current = handle.takeIf { it != 0L } ?: return - val trackId = if (trackIndex < 0) { - -1 - } else { - resolveTrackId(trackIndex, decodeTracks { NativePlayerBridge.subtitleTracksJson(it) }) ?: return - } - NativePlayerBridge.clearExternalSubtitlesAndSelect(current, trackId) - } - - override fun setSubtitleDelayMs(delayMs: Int) { - handle.takeIf { it != 0L }?.let { current -> - NativePlayerBridge.setSubtitleDelayMs( - current, - delayMs.coerceIn(SUBTITLE_DELAY_MIN_MS, SUBTITLE_DELAY_MAX_MS), - ) - } - } - - override fun applySubtitleStyle(style: SubtitleStyleState) { - handle.takeIf { it != 0L }?.let { current -> - NativePlayerBridge.applySubtitleStyle( - handle = current, - textColor = style.textColor.toMpvColorString(), - backgroundColor = style.backgroundColor.toMpvColorString(), - outlineColor = style.outlineColor.toMpvColorString(), - outlineSize = if (style.outlineEnabled) style.outlineWidth.toFloat() else 0f, - bold = style.bold, - fontSize = style.toMpvSubtitleFontSize(), - subPos = style.toMpvSubtitlePosition(), - ) - } - } - - private fun decodeTracks(readJson: (Long) -> String): List { - val current = handle.takeIf { it != 0L } ?: return emptyList() - return runCatching { - json.decodeFromString>(readJson(current)) - }.getOrDefault(emptyList()) - } -} - -@Serializable -private data class NativeMpvTrack( - val index: Int = 0, - val id: String = "", - val label: String = "", - val language: String = "", - val selected: Boolean = false, - val forced: Boolean = false, -) - -private fun resolveTrackId(index: Int, tracks: List): Int? = - tracks.firstNotNullOfOrNull { track -> - if (track.index == index) { - track.id.toIntOrNull() - } else { - null - } - } ?: tracks.getOrNull(index)?.id?.toIntOrNull() - -private fun Color.toMpvColorString(): String { - val alphaInt = (alpha * 255f).toInt().coerceIn(0, 255) - val redInt = (red * 255f).toInt().coerceIn(0, 255) - val greenInt = (green * 255f).toInt().coerceIn(0, 255) - val blueInt = (blue * 255f).toInt().coerceIn(0, 255) - return buildString { - append('#') - append(alphaInt.toHexByte()) - append(redInt.toHexByte()) - append(greenInt.toHexByte()) - append(blueInt.toHexByte()) - } -} - -private fun SubtitleStyleState.toMpvSubtitlePosition(): Int = - (100 - (bottomOffset / 2)).coerceIn(0, 150) - -private fun SubtitleStyleState.toMpvSubtitleFontSize(): Float = - (fontSizeSp * 3f).coerceIn(24f, 96f) - -private fun Int.toHexByte(): String { - val digits = "0123456789ABCDEF" - val value = coerceIn(0, 255) - return buildString { - append(digits[value / 16]) - append(digits[value % 16]) - } -} - -private data class PendingSource( - val sourceUrl: String, - val headerLines: List, - val playWhenReady: Boolean, - val initialPositionMs: Long, - val onError: (String?) -> Unit, -) - -private fun Map.toHeaderLines(): List = - entries.mapNotNull { (key, value) -> - val cleanKey = key.trim() - val cleanValue = value.trim() - if (cleanKey.isBlank() || cleanValue.isBlank()) { - null - } else { - "$cleanKey: $cleanValue" - } - } - -private fun List.toHeaderMap(): Map = - mapNotNull { line -> - val separator = line.indexOf(':') - if (separator <= 0) return@mapNotNull null - line.substring(0, separator).trim() to line.substring(separator + 1).trim() - }.toMap() - -private fun String.toPlayerControlsAction(): PlayerControlsAction? = - when (this) { - "toggleChrome" -> PlayerControlsAction.ToggleChrome - "revealLockedOverlay" -> PlayerControlsAction.RevealLockedOverlay - "back" -> PlayerControlsAction.Back - "toggle" -> PlayerControlsAction.TogglePlayback - "keyboardToggle" -> PlayerControlsAction.KeyboardTogglePlayback - "seekBack" -> PlayerControlsAction.SeekBack - "keyboardSeekBack" -> PlayerControlsAction.KeyboardSeekBack - "seekForward" -> PlayerControlsAction.SeekForward - "keyboardSeekForward" -> PlayerControlsAction.KeyboardSeekForward - "resize" -> PlayerControlsAction.ResizeMode - "speed" -> PlayerControlsAction.Speed - "subtitles" -> PlayerControlsAction.Subtitles - "audio" -> PlayerControlsAction.Audio - "sources" -> PlayerControlsAction.Sources - "episodes" -> PlayerControlsAction.Episodes - "external" -> PlayerControlsAction.OpenExternalPlayer - "submitIntro" -> PlayerControlsAction.SubmitIntro - "lock" -> PlayerControlsAction.LockToggle - "videoSettings" -> PlayerControlsAction.VideoSettings - else -> null - } - -private fun PlayerControlsState.toControlsJson(): String = - buildString { - append('{') - appendJsonField("title", title) - append(',') - appendJsonField("episodeText", episodeText) - append(',') - appendJsonField("streamTitle", streamTitle) - append(',') - appendJsonField("providerName", providerName) - append(',') - appendJsonField("pauseOverlayWatchingLabel", pauseOverlayWatchingLabel) - append(',') - appendJsonField("pauseOverlayLogo", pauseOverlayLogo.orEmpty()) - append(',') - appendJsonField("pauseOverlayEpisodeInfo", pauseOverlayEpisodeInfo) - append(',') - appendJsonField("pauseOverlayEpisodeTitle", pauseOverlayEpisodeTitle) - append(',') - appendJsonField("pauseOverlayDescription", pauseOverlayDescription) - append(',') - appendJsonField("resizeModeLabel", resizeModeLabel) - append(',') - appendJsonField("playbackSpeedLabel", playbackSpeedLabel) - append(',') - appendJsonField("subtitlesLabel", subtitlesLabel) - append(',') - appendJsonField("audioLabel", audioLabel) - append(',') - appendJsonField("sourcesLabel", sourcesLabel) - append(',') - appendJsonField("episodesLabel", episodesLabel) - append(',') - appendJsonField("externalPlayerLabel", externalPlayerLabel) - append(',') - appendJsonField("playLabel", playLabel) - append(',') - appendJsonField("pauseLabel", pauseLabel) - append(',') - appendJsonField("closeLabel", closeLabel) - append(',') - appendJsonField("lockLabel", lockLabel) - append(',') - appendJsonField("unlockLabel", unlockLabel) - append(',') - appendJsonField("submitIntroLabel", submitIntroLabel) - append(',') - appendJsonField("videoSettingsLabel", videoSettingsLabel) - append(',') - appendJsonField("tapToUnlockLabel", tapToUnlockLabel) - append(',') - appendJsonField("playbackErrorTitle", playbackErrorTitle) - append(',') - appendJsonField("playbackErrorMessage", playbackErrorMessage) - append(',') - appendJsonField("playbackErrorActionLabel", playbackErrorActionLabel) - append(',') - appendJsonField("sourcesPanelTitle", sourcesPanelTitle) - append(',') - appendJsonField("episodesPanelTitle", episodesPanelTitle) - append(',') - appendJsonField("streamsPanelTitle", streamsPanelTitle) - append(',') - appendJsonField("allFilterLabel", allFilterLabel) - append(',') - appendJsonField("reloadLabel", reloadLabel) - append(',') - appendJsonField("backLabel", backLabel) - append(',') - appendJsonField("panelCloseLabel", panelCloseLabel) - append(',') - appendJsonField("cancelLabel", cancelLabel) - append(',') - appendJsonField("playingLabel", playingLabel) - append(',') - appendJsonField("noStreamsLabel", noStreamsLabel) - append(',') - appendJsonField("noEpisodesLabel", noEpisodesLabel) - append(',') - appendJsonField("submitIntroPanelTitle", submitIntroPanelTitle) - append(',') - appendJsonField("submitIntroSegmentTypeLabel", submitIntroSegmentTypeLabel) - append(',') - appendJsonField("submitIntroSegmentIntroLabel", submitIntroSegmentIntroLabel) - append(',') - appendJsonField("submitIntroSegmentRecapLabel", submitIntroSegmentRecapLabel) - append(',') - appendJsonField("submitIntroSegmentOutroLabel", submitIntroSegmentOutroLabel) - append(',') - appendJsonField("submitIntroStartTimeLabel", submitIntroStartTimeLabel) - append(',') - appendJsonField("submitIntroEndTimeLabel", submitIntroEndTimeLabel) - append(',') - appendJsonField("submitIntroCaptureLabel", submitIntroCaptureLabel) - append(',') - appendJsonField("submitIntroSubmitLabel", submitIntroSubmitLabel) - append(',') - appendJsonField("p2pConsentTitle", p2pConsentTitle) - append(',') - appendJsonField("p2pConsentBody", p2pConsentBody) - append(',') - appendJsonField("p2pConsentEnableLabel", p2pConsentEnableLabel) - append(',') - appendJsonField("p2pConsentCancelLabel", p2pConsentCancelLabel) - append(',') - appendJsonField("subtitlesPanelTitle", subtitlesPanelTitle) - append(',') - appendJsonField("subtitleBuiltInTabLabel", subtitleBuiltInTabLabel) - append(',') - appendJsonField("subtitleAddonsTabLabel", subtitleAddonsTabLabel) - append(',') - appendJsonField("subtitleStyleTabLabel", subtitleStyleTabLabel) - append(',') - appendJsonField("noneLabel", noneLabel) - append(',') - appendJsonField("fetchSubtitlesLabel", fetchSubtitlesLabel) - append(',') - appendJsonField("subtitleDelayLabel", subtitleDelayLabel) - append(',') - appendJsonField("resetLabel", resetLabel) - append(',') - appendJsonField("autoSyncLabel", autoSyncLabel) - append(',') - appendJsonField("reloadSmallLabel", reloadSmallLabel) - append(',') - appendJsonField("captureLineLabel", captureLineLabel) - append(',') - appendJsonField("selectAddonSubtitleFirstLabel", selectAddonSubtitleFirstLabel) - append(',') - appendJsonField("loadingSubtitleLinesLabel", loadingSubtitleLinesLabel) - append(',') - appendJsonField("fontSizeLabel", fontSizeLabel) - append(',') - appendJsonField("outlineLabel", outlineLabel) - append(',') - appendJsonField("boldLabel", boldLabel) - append(',') - appendJsonField("bottomOffsetLabel", bottomOffsetLabel) - append(',') - appendJsonField("colorLabel", colorLabel) - append(',') - appendJsonField("textOpacityLabel", textOpacityLabel) - append(',') - appendJsonField("outlineColorLabel", outlineColorLabel) - append(',') - appendJsonField("resetDefaultsLabel", resetDefaultsLabel) - append(',') - appendJsonField("onLabel", onLabel) - append(',') - appendJsonField("offLabel", offLabel) - append(',') - appendJsonField("themeAccentColor", themeAccentColor) - append(',') - appendJsonField("themeAccentStrongColor", themeAccentStrongColor) - append(',') - appendJsonField("themeOnAccentColor", themeOnAccentColor) - append(',') - appendJsonField("themeFocusColor", themeFocusColor) - append(',') - appendJsonField("themeSelectedSurfaceColor", themeSelectedSurfaceColor) - append(',') - appendJsonField("themeSelectedSurfaceHoverColor", themeSelectedSurfaceHoverColor) - append(',') - appendJsonField("themeSelectedRingColor", themeSelectedRingColor) - append(',') - appendJsonField("themeTimelineFillColor", themeTimelineFillColor) - append(',') - appendJsonField("themeTimelineTrackColor", themeTimelineTrackColor) - append(',') - appendJsonField("themeBufferingColor", themeBufferingColor) - append(',') - appendJsonField("themeBufferingTrackColor", themeBufferingTrackColor) - append(',') - appendJsonField("themeControlForegroundColor", themeControlForegroundColor) - append(',') - appendJsonField("isPlaying", isPlaying) - append(',') - appendJsonField("isLoading", isLoading) - append(',') - appendJsonField("isLocked", isLocked) - append(',') - appendJsonField("lockedOverlayVisible", lockedOverlayVisible) - append(',') - appendJsonField("controlsVisible", controlsVisible) - append(',') - appendJsonArrayField("parentalWarnings", parentalWarnings) { appendParentalWarningJson(it) } - append(',') - appendJsonField("showParentalGuide", showParentalGuide) - append(',') - appendJsonField("showOpeningOverlay", showOpeningOverlay) - append(',') - appendJsonField("openingArtwork", openingArtwork.orEmpty()) - append(',') - appendJsonField("openingLogo", openingLogo.orEmpty()) - append(',') - appendJsonField("openingTitle", openingTitle) - append(',') - appendJsonField("openingMessage", openingMessage.orEmpty()) - append(',') - appendJsonField("openingProgress", openingProgress) - append(',') - appendJsonField("skipPromptVisible", skipPromptVisible) - append(',') - appendJsonField("skipPromptLabel", skipPromptLabel) - append(',') - appendJsonField("skipPromptStartMs", skipPromptStartMs) - append(',') - appendJsonField("skipPromptEndMs", skipPromptEndMs) - append(',') - appendJsonField("skipPromptDismissed", skipPromptDismissed) - append(',') - appendJsonField("nextEpisodeVisible", nextEpisodeVisible) - append(',') - appendJsonField("nextEpisodeHeaderLabel", nextEpisodeHeaderLabel) - append(',') - appendJsonField("nextEpisodeTitle", nextEpisodeTitle) - append(',') - appendJsonField("nextEpisodeThumbnail", nextEpisodeThumbnail) - append(',') - appendJsonField("nextEpisodeStatus", nextEpisodeStatus) - append(',') - appendJsonField("nextEpisodeActionLabel", nextEpisodeActionLabel) - append(',') - appendJsonField("nextEpisodePlayable", nextEpisodePlayable) - append(',') - appendJsonField("showSubmitIntro", showSubmitIntro) - append(',') - appendJsonField("showVideoSettings", showVideoSettings) - append(',') - appendJsonField("showSources", showSources) - append(',') - appendJsonField("showEpisodes", showEpisodes) - append(',') - appendJsonField("showExternalPlayer", showExternalPlayer) - append(',') - appendJsonField("durationMs", durationMs) - append(',') - appendJsonField("positionMs", positionMs) - append(',') - appendJsonField("sourceIsLoading", sourceIsLoading) - append(',') - appendJsonArrayField("sourceFilters", sourceFilters) { appendFilterItemJson(it) } - append(',') - appendJsonArrayField("sourceItems", sourceItems) { appendSourceItemJson(it) } - append(',') - appendJsonArrayField("episodeItems", episodeItems) { appendEpisodeItemJson(it) } - append(',') - appendJsonArrayField("episodeSeasons", episodeSeasons) { appendSeasonItemJson(it) } - append(',') - appendJsonField("episodeStreamsVisible", episodeStreamsVisible) - append(',') - appendJsonField("episodeStreamsIsLoading", episodeStreamsIsLoading) - append(',') - appendJsonField("selectedEpisodeLabel", selectedEpisodeLabel) - append(',') - appendJsonArrayField("episodeStreamFilters", episodeStreamFilters) { appendFilterItemJson(it) } - append(',') - appendJsonArrayField("episodeStreamItems", episodeStreamItems) { appendSourceItemJson(it) } - append(',') - appendJsonField("submitIntroSegmentType", submitIntroSegmentType) - append(',') - appendJsonField("submitIntroStartTime", submitIntroStartTime) - append(',') - appendJsonField("submitIntroEndTime", submitIntroEndTime) - append(',') - appendJsonField("isSubmitIntroSubmitting", isSubmitIntroSubmitting) - append(',') - appendJsonField("submitIntroStatusMessage", submitIntroStatusMessage) - append(',') - appendJsonField("showP2pConsent", showP2pConsent) - append(',') - appendJsonField("subtitleActiveTab", subtitleActiveTab) - append(',') - appendJsonArrayField("addonSubtitleItems", addonSubtitleItems) { appendAddonSubtitleItemJson(it) } - append(',') - appendJsonField("isLoadingAddonSubtitles", isLoadingAddonSubtitles) - append(',') - appendJsonField("selectedAddonSubtitleId", selectedAddonSubtitleId) - append(',') - appendJsonField("useCustomSubtitles", useCustomSubtitles) - append(',') - appendJsonField("subtitleDelayMs", subtitleDelayMs) - append(',') - appendJsonField("hasSelectedAddonSubtitle", hasSelectedAddonSubtitle) - append(',') - appendJsonField("subtitleAutoSyncCapturedPositionMs", subtitleAutoSyncCapturedPositionMs) - append(',') - appendJsonArrayField("subtitleAutoSyncCues", subtitleAutoSyncCues) { appendSubtitleCueItemJson(it) } - append(',') - appendJsonField("subtitleAutoSyncIsLoading", subtitleAutoSyncIsLoading) - append(',') - appendJsonField("subtitleAutoSyncErrorMessage", subtitleAutoSyncErrorMessage) - append(',') - appendJsonField("subtitleStyle", subtitleStyle) - append(',') - appendJsonArrayField("subtitleColorSwatches", SubtitleColorSwatches.map { it.toStorageHexString() }) { append(it.toJsonString()) } - append(',') - appendJsonField("closeModalsToken", closeModalsToken) - append('}') - } - -private fun PlayerControlsState.nativeControlsStructureKey(): PlayerControlsState = - copy( - isPlaying = false, - isLoading = false, - durationMs = 0L, - positionMs = 0L, - ) - -private fun StringBuilder.appendJsonField(name: String, value: String) { - append('"').append(name).append("\":") - append(value.toJsonString()) -} - -private fun StringBuilder.appendJsonField(name: String, value: Boolean) { - append('"').append(name).append("\":").append(value) -} - -private fun StringBuilder.appendJsonField(name: String, value: Long) { - append('"').append(name).append("\":").append(value) -} - -private fun StringBuilder.appendJsonField(name: String, value: Float?) { - append('"').append(name).append("\":") - if (value == null || value.isNaN() || value.isInfinite()) { - append("null") - } else { - append(value.coerceIn(0f, 1f)) - } -} - -private fun StringBuilder.appendJsonField(name: String, value: Int) { - append('"').append(name).append("\":").append(value) -} - -private fun StringBuilder.appendJsonField(name: String, value: SubtitleStyleState) { - append('"').append(name).append("\":") - appendSubtitleStyleJson(value) -} - -private inline fun StringBuilder.appendJsonArrayField( - name: String, - values: List, - appendValue: StringBuilder.(T) -> Unit, -) { - append('"').append(name).append("\":[") - values.forEachIndexed { index, value -> - if (index > 0) append(',') - appendValue(value) - } - append(']') -} - -private fun StringBuilder.appendFilterItemJson(item: PlayerControlFilterItem) { - append('{') - appendJsonField("id", item.id) - append(',') - appendJsonField("label", item.label) - append(',') - appendJsonField("isSelected", item.isSelected) - append(',') - appendJsonField("isLoading", item.isLoading) - append(',') - appendJsonField("hasError", item.hasError) - append('}') -} - -private fun StringBuilder.appendSeasonItemJson(item: PlayerControlSeasonItem) { - append('{') - appendJsonField("season", item.season) - append(',') - appendJsonField("label", item.label) - append(',') - appendJsonField("isSelected", item.isSelected) - append('}') -} - -private fun StringBuilder.appendSourceItemJson(item: PlayerControlSourceItem) { - append('{') - appendJsonField("index", item.index) - append(',') - appendJsonField("filterId", item.filterId) - append(',') - appendJsonField("label", item.label) - append(',') - appendJsonField("subtitle", item.subtitle) - append(',') - appendJsonField("addonName", item.addonName) - append(',') - appendJsonField("isCurrent", item.isCurrent) - append(',') - appendJsonField("isEnabled", item.isEnabled) - append('}') -} - -private fun StringBuilder.appendEpisodeItemJson(item: PlayerControlEpisodeItem) { - append('{') - appendJsonField("index", item.index) - append(',') - appendJsonField("id", item.id) - append(',') - appendJsonField("title", item.title) - append(',') - appendJsonField("code", item.code) - append(',') - appendJsonField("overview", item.overview) - append(',') - appendJsonField("thumbnail", item.thumbnail) - append(',') - appendJsonField("season", item.season) - append(',') - appendJsonField("episode", item.episode) - append(',') - appendJsonField("isCurrent", item.isCurrent) - append(',') - appendJsonField("isWatched", item.isWatched) - append('}') -} - -private fun StringBuilder.appendAddonSubtitleItemJson(item: PlayerControlAddonSubtitleItem) { - append('{') - appendJsonField("index", item.index) - append(',') - appendJsonField("id", item.id) - append(',') - appendJsonField("display", item.display) - append(',') - appendJsonField("languageLabel", item.languageLabel) - append(',') - appendJsonField("addonName", item.addonName) - append(',') - appendJsonField("isSelected", item.isSelected) - append('}') -} - -private fun StringBuilder.appendSubtitleCueItemJson(item: PlayerControlSubtitleCueItem) { - append('{') - appendJsonField("index", item.index) - append(',') - appendJsonField("timeMs", item.timeMs) - append(',') - appendJsonField("timeLabel", item.timeLabel) - append(',') - appendJsonField("text", item.text) - append('}') -} - -private fun StringBuilder.appendParentalWarningJson(item: ParentalWarning) { - append('{') - appendJsonField("label", item.label) - append(',') - appendJsonField("severity", item.severity) - append('}') -} - -private fun StringBuilder.appendSubtitleStyleJson(style: SubtitleStyleState) { - append('{') - appendJsonField("textColor", style.textColor.toStorageHexString()) - append(',') - appendJsonField("outlineColor", style.outlineColor.toStorageHexString()) - append(',') - appendJsonField("outlineEnabled", style.outlineEnabled) - append(',') - appendJsonField("bold", style.bold) - append(',') - appendJsonField("fontSizeSp", style.fontSizeSp) - append(',') - appendJsonField("bottomOffset", style.bottomOffset) - append('}') -} - -private fun String.toJsonString(): String = - buildString(length + 2) { - append('"') - for (char in this@toJsonString) { - when (char) { - '\\' -> append("\\\\") - '"' -> append("\\\"") - '\b' -> append("\\b") - '\u000C' -> append("\\f") - '\n' -> append("\\n") - '\r' -> append("\\r") - '\t' -> append("\\t") - else -> { - if (char.code < 0x20) { - append("\\u") - append(char.code.toString(16).padStart(4, '0')) - } else { - append(char) - } - } - } - } - append('"') - } diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerHost.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerHost.kt deleted file mode 100644 index a1675948..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerHost.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.nuvio.app.features.player.desktop - -import java.awt.Canvas -import java.awt.Color -import java.awt.Graphics - -internal class NativePlayerHost : Canvas() { - var onPeerReady: (() -> Unit)? = null - var onDisplayableChanged: ((Boolean) -> Unit)? = null - var onFirstPaint: (() -> Unit)? = null - var onFirstFullSizePaint: (() -> Unit)? = null - private var firstPaintNotified = false - private var firstFullSizePaintNotified = false - - init { - background = Color.BLACK - ignoreRepaint = false - } - - override fun update(graphics: Graphics) { - paint(graphics) - } - - override fun paint(graphics: Graphics) { - graphics.color = Color.BLACK - graphics.fillRect(0, 0, width, height) - if (!firstPaintNotified) { - firstPaintNotified = true - onFirstPaint?.invoke() - } - if (!firstFullSizePaintNotified && width > 1 && height > 1) { - firstFullSizePaintNotified = true - onFirstFullSizePaint?.invoke() - } - } - - override fun addNotify() { - super.addNotify() - onDisplayableChanged?.invoke(true) - repaint() - onPeerReady?.invoke() - } - - override fun removeNotify() { - onDisplayableChanged?.invoke(false) - firstPaintNotified = false - firstFullSizePaintNotified = false - onPeerReady = null - onFirstPaint = null - onFirstFullSizePaint = null - super.removeNotify() - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/skip/DateComponents.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/skip/DateComponents.desktop.kt deleted file mode 100644 index 443eebab..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/skip/DateComponents.desktop.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.nuvio.app.features.player.skip - -import java.time.LocalDate - -internal actual fun currentDateComponents(): DateComponents { - val today = LocalDate.now() - return DateComponents( - year = today.year, - month = today.monthValue, - day = today.dayOfMonth, - ) -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.desktop.kt deleted file mode 100644 index 0b73ade0..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.desktop.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.nuvio.app.features.plugins - -import java.security.MessageDigest -import java.util.Base64 -import javax.crypto.Mac -import javax.crypto.spec.SecretKeySpec - -internal fun pluginDigestHex(algorithm: String, data: String): String { - val normalized = algorithm.uppercase() - val digest = MessageDigest.getInstance(normalized).digest(data.encodeToByteArray()) - return digest.joinToString(separator = "") { byte -> - byte.toUByte().toString(16).padStart(2, '0') - } -} - -internal fun pluginHmacHex(algorithm: String, key: String, data: String): String { - val normalized = when (algorithm.uppercase()) { - "SHA1" -> "HmacSHA1" - "SHA256" -> "HmacSHA256" - "SHA512" -> "HmacSHA512" - "MD5" -> "HmacMD5" - else -> error("Unsupported HMAC algorithm: $algorithm") - } - val mac = Mac.getInstance(normalized) - mac.init(SecretKeySpec(key.encodeToByteArray(), normalized)) - val digest = mac.doFinal(data.encodeToByteArray()) - return digest.joinToString(separator = "") { byte -> - byte.toUByte().toString(16).padStart(2, '0') - } -} - -internal fun pluginBase64Encode(data: String): String = - Base64.getEncoder().encodeToString(data.encodeToByteArray()) - -internal fun pluginBase64Decode(data: String): String { - val normalized = data.trim().replace("\n", "").replace("\r", "").replace(" ", "") - val decoded = Base64.getDecoder().decode(normalized) - return decoded.decodeToString() -} - -internal fun pluginUtf8ToHex(value: String): String = - value.encodeToByteArray().joinToString(separator = "") { byte -> - byte.toUByte().toString(16).padStart(2, '0') - } - -internal fun pluginHexToUtf8(hex: String): String { - val normalized = hex.trim().lowercase() - .replace(" ", "") - .removePrefix("0x") - if (normalized.isEmpty()) return "" - - val evenHex = if (normalized.length % 2 == 0) normalized else "0$normalized" - val out = ByteArray(evenHex.length / 2) - for (index in out.indices) { - val part = evenHex.substring(index * 2, index * 2 + 2) - out[index] = part.toInt(16).toByte() - } - return out.decodeToString() -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginPlatform.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginPlatform.desktop.kt deleted file mode 100644 index 1217b448..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginPlatform.desktop.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.nuvio.app.features.plugins - -import com.nuvio.app.core.storage.DesktopStorage -import java.util.Locale - -internal object PluginStorage { - private const val pluginsStateKey = "plugins_state" - private val store = DesktopStorage.store("nuvio_plugins") - - fun loadState(profileId: Int): String? = - store.getString("${pluginsStateKey}_$profileId") - - fun saveState(profileId: Int, payload: String) { - store.putString("${pluginsStateKey}_$profileId", payload) - } -} - -internal fun currentPluginPlatform(): String = "desktop" - -internal fun currentPluginPlatformTags(): Set { - val osName = System.getProperty("os.name").orEmpty().lowercase(Locale.ROOT) - val osTag = when { - osName.contains("mac") || osName.contains("darwin") -> "macos" - osName.contains("win") -> "windows" - osName.contains("linux") -> "linux" - else -> null - } - return buildSet { - add(currentPluginPlatform()) - add("jvm") - osTag?.let(::add) - } -} - -internal fun currentEpochMillis(): Long = System.currentTimeMillis() diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/AvatarStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/AvatarStorage.desktop.kt deleted file mode 100644 index 8c00aeda..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/AvatarStorage.desktop.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.nuvio.app.features.profiles - -import com.nuvio.app.core.storage.DesktopStorage - -internal actual object AvatarStorage { - private val store = DesktopStorage.store("nuvio_avatars") - - actual fun loadPayload(): String? = store.getString("avatars") - - actual fun savePayload(payload: String) { - store.putString("avatars", payload) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.desktop.kt deleted file mode 100644 index 486f1477..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.desktop.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.nuvio.app.features.profiles - -internal actual object ProfileHoverHapticFeedback { - actual fun prepare() = Unit - actual fun perform() = Unit - actual fun release() = Unit -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCacheStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCacheStorage.desktop.kt deleted file mode 100644 index bcca6616..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCacheStorage.desktop.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.nuvio.app.features.profiles - -import com.nuvio.app.core.storage.DesktopStorage - -internal actual object ProfilePinCacheStorage { - private val store = DesktopStorage.store("nuvio_profile_pin_cache") - - actual fun loadPayload(profileIndex: Int): String? = - store.getString(key(profileIndex)) - - actual fun savePayload(profileIndex: Int, payload: String) { - store.putString(key(profileIndex), payload) - } - - actual fun removePayload(profileIndex: Int) { - store.remove(key(profileIndex)) - } - - private fun key(profileIndex: Int): String = "profile_pin_$profileIndex" -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCrypto.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCrypto.desktop.kt deleted file mode 100644 index 70cd338b..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCrypto.desktop.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.nuvio.app.features.profiles - -import java.security.MessageDigest - -internal actual object ProfilePinCrypto { - actual fun sha256Hex(value: String): String = - MessageDigest - .getInstance("SHA-256") - .digest(value.toByteArray(Charsets.UTF_8)) - .joinToString("") { byte -> "%02x".format(byte) } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfileStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfileStorage.desktop.kt deleted file mode 100644 index 38b7776d..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfileStorage.desktop.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.nuvio.app.features.profiles - -import com.nuvio.app.core.storage.DesktopStorage - -internal actual object ProfileStorage { - private val store = DesktopStorage.store("nuvio_profiles") - - actual fun loadPayload(): String? = store.getString("profiles") - - actual fun savePayload(payload: String) { - store.putString("profiles", payload) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/search/SearchHistoryStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/search/SearchHistoryStorage.desktop.kt deleted file mode 100644 index 639c2b8e..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/search/SearchHistoryStorage.desktop.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.nuvio.app.features.search - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey - -internal actual object SearchHistoryStorage { - private val store = DesktopStorage.store("nuvio_search_history") - - actual fun loadPayload(): String? = - store.getString(ProfileScopedKey.of("search_history")) - - actual fun savePayload(payload: String) { - store.putString(ProfileScopedKey.of("search_history"), payload) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.desktop.kt deleted file mode 100644 index e3f4a5f0..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.desktop.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.nuvio.app.features.settings - -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.painter.Painter -import nuvio.composeapp.generated.resources.Res -import nuvio.composeapp.generated.resources.introdb_favicon -import nuvio.composeapp.generated.resources.mdblist_logo -import nuvio.composeapp.generated.resources.rating_tmdb -import nuvio.composeapp.generated.resources.trakt_tv_favicon -import org.jetbrains.compose.resources.painterResource - -@Composable -internal actual fun integrationLogoPainter(logo: IntegrationLogo): Painter = - painterResource( - when (logo) { - IntegrationLogo.Tmdb -> Res.drawable.rating_tmdb - IntegrationLogo.Trakt -> Res.drawable.trakt_tv_favicon - IntegrationLogo.MdbList -> Res.drawable.mdblist_logo - IntegrationLogo.IntroDb -> Res.drawable.introdb_favicon - }, - ) diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/PluginsSettingsPage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/PluginsSettingsPage.desktop.kt deleted file mode 100644 index 31fed517..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/PluginsSettingsPage.desktop.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.nuvio.app.features.settings - -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.ui.Modifier -import com.nuvio.app.features.plugins.PluginsSettingsPageContent - -internal actual fun LazyListScope.pluginsSettingsContent() { - item { - PluginsSettingsPageContent( - modifier = Modifier.fillMaxWidth(), - ) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.desktop.kt deleted file mode 100644 index 0ca7369e..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.desktop.kt +++ /dev/null @@ -1,83 +0,0 @@ -package com.nuvio.app.features.settings - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey -import com.nuvio.app.core.sync.decodeSyncBoolean -import com.nuvio.app.core.sync.decodeSyncString -import com.nuvio.app.core.sync.encodeSyncBoolean -import com.nuvio.app.core.sync.encodeSyncString -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put -import java.util.Locale - -internal actual object ThemeSettingsStorage { - private const val selectedThemeKey = "selected_theme" - private const val amoledEnabledKey = "amoled_enabled" - private const val liquidGlassNativeTabBarEnabledKey = "liquid_glass_native_tab_bar_enabled" - private const val desktopNavigationLayoutKey = "desktop_navigation_layout" - private const val selectedAppLanguageKey = "selected_app_language" - private val profileScopedSyncKeys = listOf( - selectedThemeKey, - amoledEnabledKey, - liquidGlassNativeTabBarEnabledKey, - desktopNavigationLayoutKey, - ) - private val store = DesktopStorage.store("nuvio_theme_settings") - - actual fun loadSelectedTheme(): String? = - store.getString(ProfileScopedKey.of(selectedThemeKey)) - - actual fun saveSelectedTheme(themeName: String) { - store.putString(ProfileScopedKey.of(selectedThemeKey), themeName) - } - - actual fun loadAmoledEnabled(): Boolean? = - store.getBoolean(ProfileScopedKey.of(amoledEnabledKey)) - - actual fun saveAmoledEnabled(enabled: Boolean) { - store.putBoolean(ProfileScopedKey.of(amoledEnabledKey), enabled) - } - - actual fun loadLiquidGlassNativeTabBarEnabled(): Boolean? = - store.getBoolean(ProfileScopedKey.of(liquidGlassNativeTabBarEnabledKey)) - - actual fun saveLiquidGlassNativeTabBarEnabled(enabled: Boolean) { - store.putBoolean(ProfileScopedKey.of(liquidGlassNativeTabBarEnabledKey), enabled) - } - - actual fun loadDesktopNavigationLayout(): String? = - store.getString(ProfileScopedKey.of(desktopNavigationLayoutKey)) - - actual fun saveDesktopNavigationLayout(layoutName: String) { - store.putString(ProfileScopedKey.of(desktopNavigationLayoutKey), layoutName) - } - - actual fun loadSelectedAppLanguage(): String? = - store.getString(selectedAppLanguageKey) - ?: Locale.getDefault().toLanguageTag().takeIf { it.isNotBlank() } - - actual fun saveSelectedAppLanguage(languageCode: String) { - store.putString(selectedAppLanguageKey, languageCode) - } - - actual fun applySelectedAppLanguage(languageCode: String) { - Locale.setDefault(Locale.forLanguageTag(languageCode)) - } - - actual fun exportToSyncPayload(): JsonObject = buildJsonObject { - loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) } - loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) } - loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) } - loadDesktopNavigationLayout()?.let { put(desktopNavigationLayoutKey, encodeSyncString(it)) } - } - - actual fun replaceFromSyncPayload(payload: JsonObject) { - store.removeAll(profileScopedSyncKeys.map(ProfileScopedKey::of)) - payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme) - payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled) - payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled) - payload.decodeSyncString(desktopNavigationLayoutKey)?.let(::saveDesktopNavigationLayout) - applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.desktop.kt deleted file mode 100644 index 3f83db89..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.desktop.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.nuvio.app.features.streams - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey - -internal actual object BingeGroupCacheStorage { - private val store = DesktopStorage.store("nuvio_binge_group_cache") - - actual fun load(hashedKey: String): String? = - store.getString(ProfileScopedKey.of(hashedKey)) - - actual fun save(hashedKey: String, value: String) { - store.putString(ProfileScopedKey.of(hashedKey), value) - } - - actual fun remove(hashedKey: String) { - store.remove(ProfileScopedKey.of(hashedKey)) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/EpochMs.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/EpochMs.desktop.kt deleted file mode 100644 index 1cc7d469..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/EpochMs.desktop.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.nuvio.app.features.streams - -internal actual fun epochMs(): Long = System.currentTimeMillis() diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.desktop.kt deleted file mode 100644 index f801c332..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.desktop.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.nuvio.app.features.streams - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey -import com.nuvio.app.core.sync.decodeSyncBoolean -import com.nuvio.app.core.sync.decodeSyncString -import com.nuvio.app.core.sync.encodeSyncBoolean -import com.nuvio.app.core.sync.encodeSyncString -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put - -internal actual object StreamBadgeSettingsStorage { - private const val streamBadgeRulesKey = "stream_badge_rules" - private const val showFileSizeBadgesKey = "show_file_size_badges" - private const val streamBadgePlacementKey = "stream_badge_placement" - private const val legacyDebridStreamBadgeRulesKey = "debrid_stream_badge_rules" - private val syncKeys = listOf(streamBadgeRulesKey, showFileSizeBadgesKey, streamBadgePlacementKey) - private val store = DesktopStorage.store("nuvio_stream_badge_settings") - private val legacyDebridStore = DesktopStorage.store("nuvio_debrid_settings") - - actual fun loadStreamBadgeRules(): String? = loadString(streamBadgeRulesKey) - actual fun saveStreamBadgeRules(rules: String) = saveString(streamBadgeRulesKey, rules) - actual fun loadShowFileSizeBadges(): Boolean? = loadBoolean(showFileSizeBadgesKey) - actual fun saveShowFileSizeBadges(enabled: Boolean) = saveBoolean(showFileSizeBadgesKey, enabled) - actual fun loadStreamBadgePlacement(): String? = loadString(streamBadgePlacementKey) - actual fun saveStreamBadgePlacement(placement: String) = saveString(streamBadgePlacementKey, placement) - - actual fun loadLegacyDebridStreamBadgeRules(): String? = - legacyDebridStore.getString(ProfileScopedKey.of(legacyDebridStreamBadgeRulesKey)) - - actual fun clearLegacyDebridStreamBadgeRules() { - legacyDebridStore.remove(ProfileScopedKey.of(legacyDebridStreamBadgeRulesKey)) - } - - private fun loadString(key: String): String? = store.getString(ProfileScopedKey.of(key)) - private fun saveString(key: String, value: String) = store.putString(ProfileScopedKey.of(key), value) - private fun loadBoolean(key: String): Boolean? = store.getBoolean(ProfileScopedKey.of(key)) - private fun saveBoolean(key: String, value: Boolean) = store.putBoolean(ProfileScopedKey.of(key), value) - - actual fun exportToSyncPayload(): JsonObject = buildJsonObject { - loadStreamBadgeRules()?.let { put(streamBadgeRulesKey, encodeSyncString(it)) } - loadShowFileSizeBadges()?.let { put(showFileSizeBadgesKey, encodeSyncBoolean(it)) } - loadStreamBadgePlacement()?.let { put(streamBadgePlacementKey, encodeSyncString(it)) } - } - - actual fun replaceFromSyncPayload(payload: JsonObject) { - store.removeAll(syncKeys.map(ProfileScopedKey::of)) - payload.decodeSyncString(streamBadgeRulesKey)?.let(::saveStreamBadgeRules) - payload.decodeSyncBoolean(showFileSizeBadgesKey)?.let(::saveShowFileSizeBadges) - payload.decodeSyncString(streamBadgePlacementKey)?.let(::saveStreamBadgePlacement) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheStorage.desktop.kt deleted file mode 100644 index cf5b689c..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheStorage.desktop.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.nuvio.app.features.streams - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey - -internal actual object StreamLinkCacheStorage { - private val store = DesktopStorage.store("nuvio_stream_link_cache") - - actual fun loadEntry(hashedKey: String): String? = - store.getString(ProfileScopedKey.of(hashedKey)) - - actual fun saveEntry(hashedKey: String, payload: String) { - store.putString(ProfileScopedKey.of(hashedKey), payload) - } - - actual fun removeEntry(hashedKey: String) { - store.remove(ProfileScopedKey.of(hashedKey)) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.desktop.kt deleted file mode 100644 index 349f3c6a..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.desktop.kt +++ /dev/null @@ -1,114 +0,0 @@ -package com.nuvio.app.features.tmdb - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey -import com.nuvio.app.core.sync.decodeSyncBoolean -import com.nuvio.app.core.sync.decodeSyncString -import com.nuvio.app.core.sync.encodeSyncBoolean -import com.nuvio.app.core.sync.encodeSyncString -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put - -internal actual object TmdbSettingsStorage { - private const val enabledKey = "tmdb_enabled" - private const val apiKeyKey = "tmdb_api_key" - private const val languageKey = "tmdb_language" - private const val useTrailersKey = "tmdb_use_trailers" - private const val useArtworkKey = "tmdb_use_artwork" - private const val useBasicInfoKey = "tmdb_use_basic_info" - private const val useDetailsKey = "tmdb_use_details" - private const val useCreditsKey = "tmdb_use_credits" - private const val useProductionsKey = "tmdb_use_productions" - private const val useNetworksKey = "tmdb_use_networks" - private const val useEpisodesKey = "tmdb_use_episodes" - private const val useSeasonPostersKey = "tmdb_use_season_posters" - private const val useMoreLikeThisKey = "tmdb_use_more_like_this" - private const val useCollectionsKey = "tmdb_use_collections" - private val syncKeys = listOf( - enabledKey, - apiKeyKey, - languageKey, - useTrailersKey, - useArtworkKey, - useBasicInfoKey, - useDetailsKey, - useCreditsKey, - useProductionsKey, - useNetworksKey, - useEpisodesKey, - useSeasonPostersKey, - useMoreLikeThisKey, - useCollectionsKey, - ) - private val store = DesktopStorage.store("nuvio_tmdb_settings") - - actual fun loadEnabled(): Boolean? = loadBoolean(enabledKey) - actual fun saveEnabled(enabled: Boolean) = saveBoolean(enabledKey, enabled) - actual fun loadApiKey(): String? = loadString(apiKeyKey) - actual fun saveApiKey(apiKey: String) = saveString(apiKeyKey, apiKey) - actual fun loadLanguage(): String? = loadString(languageKey) - actual fun saveLanguage(language: String) = saveString(languageKey, language) - actual fun loadUseTrailers(): Boolean? = loadBoolean(useTrailersKey) - actual fun saveUseTrailers(enabled: Boolean) = saveBoolean(useTrailersKey, enabled) - actual fun loadUseArtwork(): Boolean? = loadBoolean(useArtworkKey) - actual fun saveUseArtwork(enabled: Boolean) = saveBoolean(useArtworkKey, enabled) - actual fun loadUseBasicInfo(): Boolean? = loadBoolean(useBasicInfoKey) - actual fun saveUseBasicInfo(enabled: Boolean) = saveBoolean(useBasicInfoKey, enabled) - actual fun loadUseDetails(): Boolean? = loadBoolean(useDetailsKey) - actual fun saveUseDetails(enabled: Boolean) = saveBoolean(useDetailsKey, enabled) - actual fun loadUseCredits(): Boolean? = loadBoolean(useCreditsKey) - actual fun saveUseCredits(enabled: Boolean) = saveBoolean(useCreditsKey, enabled) - actual fun loadUseProductions(): Boolean? = loadBoolean(useProductionsKey) - actual fun saveUseProductions(enabled: Boolean) = saveBoolean(useProductionsKey, enabled) - actual fun loadUseNetworks(): Boolean? = loadBoolean(useNetworksKey) - actual fun saveUseNetworks(enabled: Boolean) = saveBoolean(useNetworksKey, enabled) - actual fun loadUseEpisodes(): Boolean? = loadBoolean(useEpisodesKey) - actual fun saveUseEpisodes(enabled: Boolean) = saveBoolean(useEpisodesKey, enabled) - actual fun loadUseSeasonPosters(): Boolean? = loadBoolean(useSeasonPostersKey) - actual fun saveUseSeasonPosters(enabled: Boolean) = saveBoolean(useSeasonPostersKey, enabled) - actual fun loadUseMoreLikeThis(): Boolean? = loadBoolean(useMoreLikeThisKey) - actual fun saveUseMoreLikeThis(enabled: Boolean) = saveBoolean(useMoreLikeThisKey, enabled) - actual fun loadUseCollections(): Boolean? = loadBoolean(useCollectionsKey) - actual fun saveUseCollections(enabled: Boolean) = saveBoolean(useCollectionsKey, enabled) - - private fun loadString(key: String): String? = store.getString(ProfileScopedKey.of(key)) - private fun saveString(key: String, value: String) = store.putString(ProfileScopedKey.of(key), value) - private fun loadBoolean(key: String): Boolean? = store.getBoolean(ProfileScopedKey.of(key)) - private fun saveBoolean(key: String, value: Boolean) = store.putBoolean(ProfileScopedKey.of(key), value) - - actual fun exportToSyncPayload(): JsonObject = buildJsonObject { - loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) } - loadApiKey()?.let { put(apiKeyKey, encodeSyncString(it)) } - loadLanguage()?.let { put(languageKey, encodeSyncString(it)) } - loadUseTrailers()?.let { put(useTrailersKey, encodeSyncBoolean(it)) } - loadUseArtwork()?.let { put(useArtworkKey, encodeSyncBoolean(it)) } - loadUseBasicInfo()?.let { put(useBasicInfoKey, encodeSyncBoolean(it)) } - loadUseDetails()?.let { put(useDetailsKey, encodeSyncBoolean(it)) } - loadUseCredits()?.let { put(useCreditsKey, encodeSyncBoolean(it)) } - loadUseProductions()?.let { put(useProductionsKey, encodeSyncBoolean(it)) } - loadUseNetworks()?.let { put(useNetworksKey, encodeSyncBoolean(it)) } - loadUseEpisodes()?.let { put(useEpisodesKey, encodeSyncBoolean(it)) } - loadUseSeasonPosters()?.let { put(useSeasonPostersKey, encodeSyncBoolean(it)) } - loadUseMoreLikeThis()?.let { put(useMoreLikeThisKey, encodeSyncBoolean(it)) } - loadUseCollections()?.let { put(useCollectionsKey, encodeSyncBoolean(it)) } - } - - actual fun replaceFromSyncPayload(payload: JsonObject) { - store.removeAll(syncKeys.map(ProfileScopedKey::of)) - payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled) - payload.decodeSyncString(apiKeyKey)?.let(::saveApiKey) - payload.decodeSyncString(languageKey)?.let(::saveLanguage) - payload.decodeSyncBoolean(useTrailersKey)?.let(::saveUseTrailers) - payload.decodeSyncBoolean(useArtworkKey)?.let(::saveUseArtwork) - payload.decodeSyncBoolean(useBasicInfoKey)?.let(::saveUseBasicInfo) - payload.decodeSyncBoolean(useDetailsKey)?.let(::saveUseDetails) - payload.decodeSyncBoolean(useCreditsKey)?.let(::saveUseCredits) - payload.decodeSyncBoolean(useProductionsKey)?.let(::saveUseProductions) - payload.decodeSyncBoolean(useNetworksKey)?.let(::saveUseNetworks) - payload.decodeSyncBoolean(useEpisodesKey)?.let(::saveUseEpisodes) - payload.decodeSyncBoolean(useSeasonPostersKey)?.let(::saveUseSeasonPosters) - payload.decodeSyncBoolean(useMoreLikeThisKey)?.let(::saveUseMoreLikeThis) - payload.decodeSyncBoolean(useCollectionsKey)?.let(::saveUseCollections) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trailer/TrailerPlaybackResolver.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trailer/TrailerPlaybackResolver.desktop.kt deleted file mode 100644 index 3c05dff1..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trailer/TrailerPlaybackResolver.desktop.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.nuvio.app.features.trailer - -actual object TrailerPlaybackResolver { - actual suspend fun resolveFromYouTubeUrl(youtubeUrl: String): TrailerPlaybackSource? = null -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktBrandPainter.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktBrandPainter.desktop.kt deleted file mode 100644 index b768fef1..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktBrandPainter.desktop.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.nuvio.app.features.trakt - -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.painter.Painter -import nuvio.composeapp.generated.resources.Res -import nuvio.composeapp.generated.resources.trakt_logo_wordmark -import nuvio.composeapp.generated.resources.trakt_tv_favicon -import org.jetbrains.compose.resources.painterResource - -@Composable -actual fun traktBrandPainter(asset: TraktBrandAsset): Painter = - painterResource( - when (asset) { - TraktBrandAsset.Glyph -> Res.drawable.trakt_tv_favicon - TraktBrandAsset.Wordmark -> Res.drawable.trakt_logo_wordmark - }, - ) diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsStorage.desktop.kt deleted file mode 100644 index bca3536f..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsStorage.desktop.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.nuvio.app.features.trakt - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey -import com.nuvio.app.core.sync.decodeSyncBoolean -import com.nuvio.app.core.sync.encodeSyncBoolean -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put - -internal actual object TraktCommentsStorage { - private const val enabledKey = "trakt_comments_enabled" - private val store = DesktopStorage.store("nuvio_trakt_comments") - - actual fun loadEnabled(): Boolean? = - store.getBoolean(ProfileScopedKey.of(enabledKey)) - - actual fun saveEnabled(enabled: Boolean) { - store.putBoolean(ProfileScopedKey.of(enabledKey), enabled) - } - - actual fun exportToSyncPayload(): JsonObject = buildJsonObject { - loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) } - } - - actual fun replaceFromSyncPayload(payload: JsonObject) { - store.remove(ProfileScopedKey.of(enabledKey)) - payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.desktop.kt deleted file mode 100644 index 6da1187c..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.desktop.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.nuvio.app.features.trakt - -import java.time.Instant -import java.time.format.DateTimeParseException - -internal actual object TraktPlatformClock { - actual fun nowEpochMs(): Long = System.currentTimeMillis() - - actual fun parseIsoDateTimeToEpochMs(value: String): Long? = - try { - Instant.parse(value).toEpochMilli() - } catch (_: DateTimeParseException) { - null - } - - actual fun availableProcessors(): Int = - Runtime.getRuntime().availableProcessors().coerceAtLeast(1) -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktStorage.desktop.kt deleted file mode 100644 index b4433d6d..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktStorage.desktop.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.nuvio.app.features.trakt - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey - -internal actual object TraktAuthStorage { - private val store = DesktopStorage.store("nuvio_trakt_auth") - - actual fun loadPayload(): String? = store.getString("trakt_auth") - - actual fun savePayload(payload: String) { - store.putString("trakt_auth", payload) - } -} - -internal actual object TraktLibraryStorage { - private val store = DesktopStorage.store("nuvio_trakt_library") - - actual fun loadPayload(): String? = - store.getString(ProfileScopedKey.of("trakt_library")) - - actual fun savePayload(payload: String) { - store.putString(ProfileScopedKey.of("trakt_library"), payload) - } -} - -internal actual object TraktSettingsStorage { - private val store = DesktopStorage.store("nuvio_trakt_settings") - - actual fun loadPayload(): String? = - store.getString(ProfileScopedKey.of("trakt_settings")) - - actual fun savePayload(payload: String) { - store.putString(ProfileScopedKey.of("trakt_settings"), payload) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watched/WatchedClock.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watched/WatchedClock.desktop.kt deleted file mode 100644 index f94a38b2..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watched/WatchedClock.desktop.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.nuvio.app.features.watched - -actual object WatchedClock { - actual fun nowEpochMs(): Long = System.currentTimeMillis() -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watched/WatchedStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watched/WatchedStorage.desktop.kt deleted file mode 100644 index 44da853a..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watched/WatchedStorage.desktop.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.nuvio.app.features.watched - -import com.nuvio.app.core.storage.DesktopStorage - -actual object WatchedStorage { - private val store = DesktopStorage.store("nuvio_watched") - - actual fun loadPayload(profileId: Int): String? = - store.getString("watched_$profileId") - - actual fun savePayload(profileId: Int, payload: String) { - store.putString("watched_$profileId", payload) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.desktop.kt deleted file mode 100644 index ce44c1c1..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.desktop.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.nuvio.app.features.watchprogress - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey - -internal actual object ContinueWatchingEnrichmentStorage { - private val store = DesktopStorage.store("nuvio_continue_watching_enrichment") - - actual fun loadPayload(key: String): String? = - store.getString(ProfileScopedKey.of(key.scopedKey())) - - actual fun savePayload(key: String, payload: String) { - store.putString(ProfileScopedKey.of(key.scopedKey()), payload) - } - - actual fun removePayload(key: String) { - store.remove(ProfileScopedKey.of(key.scopedKey())) - } - - private fun String.scopedKey(): String = "continue_watching_enrichment_$this" -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesStorage.desktop.kt deleted file mode 100644 index d3eb3d1b..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesStorage.desktop.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.nuvio.app.features.watchprogress - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey - -internal actual object ContinueWatchingPreferencesStorage { - private val store = DesktopStorage.store("nuvio_continue_watching_preferences") - - actual fun loadPayload(): String? = - store.getString(ProfileScopedKey.of("continue_watching_preferences")) - - actual fun savePayload(payload: String) { - store.putString(ProfileScopedKey.of("continue_watching_preferences"), payload) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.desktop.kt deleted file mode 100644 index ccae7137..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.desktop.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.nuvio.app.features.watchprogress - -import java.time.LocalDate - -actual object CurrentDateProvider { - actual fun todayIsoDate(): String = LocalDate.now().toString() -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/ResumePromptStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/ResumePromptStorage.desktop.kt deleted file mode 100644 index 82b82d1a..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/ResumePromptStorage.desktop.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.nuvio.app.features.watchprogress - -import com.nuvio.app.core.storage.DesktopStorage - -internal actual object ResumePromptStorage { - private val store = DesktopStorage.store("nuvio_resume_prompt") - - actual fun loadWasInPlayer(): Boolean = - store.getBoolean("was_in_player") ?: false - - actual fun saveWasInPlayer(value: Boolean) { - store.putBoolean("was_in_player", value) - } - - actual fun loadLastPlayerVideoId(): String? = - store.getString("last_player_video_id") - - actual fun saveLastPlayerVideoId(videoId: String?) { - store.putString("last_player_video_id", videoId?.takeIf { it.isNotBlank() }) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressClock.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressClock.desktop.kt deleted file mode 100644 index faead006..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressClock.desktop.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.nuvio.app.features.watchprogress - -internal actual object WatchProgressClock { - actual fun nowEpochMs(): Long = System.currentTimeMillis() -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressStorage.desktop.kt deleted file mode 100644 index cc922a39..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressStorage.desktop.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.nuvio.app.features.watchprogress - -import com.nuvio.app.core.storage.DesktopStorage - -internal actual object WatchProgressStorage { - private val store = DesktopStorage.store("nuvio_watch_progress") - - actual fun loadPayload(profileId: Int): String? = - store.getString("watch_progress_$profileId") - - actual fun savePayload(profileId: Int, payload: String) { - store.putString("watch_progress_$profileId", payload) - } -} diff --git a/composeApp/src/desktopMain/native/macos/player_bridge.mm b/composeApp/src/desktopMain/native/macos/player_bridge.mm deleted file mode 100644 index 389f383a..00000000 --- a/composeApp/src/desktopMain/native/macos/player_bridge.mm +++ /dev/null @@ -1,2663 +0,0 @@ -#import -#import -#define GL_SILENCE_DEPRECATION -#import -#import -#import -#import - -#include -#include -#include -#include - -#include -#include -#include -#include -#include - -@class PlayerMetalView; -@class MpvWebPlayer; -@class NuvioPlayerOpenGLLayer; - -@interface PlayerMetalView : NSView -- (double)edrMax; -- (double)hdrTargetPeakNits; -- (CGSize)drawableSize; -- (void)updateMetalLayerLayout; -- (void)refreshMetalLayerEdrState; -- (void)configureExtendedDynamicRange:(BOOL)enabled primaries:(NSString *)primaries targetPeakNits:(double)targetPeakNits; -- (BOOL)createMpvRenderContext:(mpv_handle *)mpv error:(NSString **)error; -- (void)destroyMpvRenderContext; -- (void)scheduleRenderUpdate; -- (void)setFullscreenTransitionActive:(BOOL)active; -@end - -@interface NuvioMainThreadPriorityLock : NSObject -- (void)beforeLocking; -- (void)afterLocked; -@end - -@interface NuvioPlayerOpenGLLayer : CAOpenGLLayer -@property(nonatomic, assign, getter=isNuvioLiveResize) BOOL nuvioLiveResize; -@property(nonatomic, assign, readonly) GLint bufferDepth; -- (instancetype)initWithOwner:(PlayerMetalView *)owner; -- (void)setMpvRenderContext:(mpv_render_context *)renderContext; -- (void)clearMpvRenderContext; -- (void)requestRender; -- (void)requestRenderForced:(BOOL)force; -- (void)lockAndSetContext; -- (void)unlockContext; -@end - -@interface PlayerScriptHandler : NSObject -@property(nonatomic, weak) MpvWebPlayer *player; -@end - -@interface MpvWebPlayer : NSObject -- (instancetype)initWithHostView:(NSView *)hostView - sourceUrl:(NSString *)sourceUrl - headerLines:(NSArray *)headerLines - playWhenReady:(BOOL)playWhenReady - initialPositionMs:(long long)initialPositionMs - controlsUrl:(NSString *)controlsUrl - javaVm:(JavaVM *)javaVm - eventSink:(jobject)eventSink - eventMethod:(jmethodID)eventMethod; -- (void)shutdown; -- (void)updateControlsJson:(NSString *)controlsJson; -- (void)setPaused:(BOOL)paused; -- (BOOL)isPaused; -- (void)seekToMilliseconds:(long long)positionMs; -- (void)seekByMilliseconds:(long long)offsetMs; -- (void)setSpeed:(double)speed; -- (double)speed; -- (void)setResizeMode:(int)mode; -- (long long)durationMs; -- (long long)positionMs; -- (long long)bufferedPositionMs; -- (BOOL)isLoading; -- (BOOL)isEnded; -- (NSString *)audioTracksJson; -- (NSString *)subtitleTracksJson; -- (void)selectAudioTrackId:(int)trackId; -- (void)selectSubtitleTrackId:(int)trackId; -- (void)addSubtitleUrl:(NSString *)url; -- (void)removeExternalSubtitles; -- (void)removeExternalSubtitlesAndSelect:(int)trackId; -- (void)setSubtitleDelayMs:(int)delayMs; -- (void)applySubtitleStyleWithTextColor:(NSString *)textColor - backgroundColor:(NSString *)backgroundColor - outlineColor:(NSString *)outlineColor - outlineSize:(double)outlineSize - bold:(BOOL)bold - fontSize:(double)fontSize - subPos:(int)subPos; -- (void)handleScriptMessage:(NSDictionary *)message; -- (void)focusControlsWebViewIfNeeded; -- (void)layoutNativeSubviews; -- (void)layoutControlsWebViewToBounds:(NSRect)bounds immediate:(BOOL)immediate; -- (void)hostViewBoundsDidChange:(NSNotification *)notification; -- (void)hostViewFrameDidChange:(NSNotification *)notification; -- (void)windowWillEnterFullScreen:(NSNotification *)notification; -- (void)windowDidEnterFullScreen:(NSNotification *)notification; -- (void)windowWillExitFullScreen:(NSNotification *)notification; -- (void)windowDidExitFullScreen:(NSNotification *)notification; -- (void)activateFullscreenTransitionWithReason:(NSString *)reason; -- (void)beginFullscreenTransitionWithReason:(NSString *)reason; -- (void)finishFullscreenTransitionWithReason:(NSString *)reason; -- (void)handleFullscreenTransitionTimer:(NSTimer *)timer; -- (void)schedulePostResizeRefreshWithReason:(NSString *)reason; -- (void)handleResizeSettleTimer:(NSTimer *)timer; -- (void)configureHdrForCurrentScreenWithReason:(NSString *)reason force:(BOOL)force; -- (void)applyHdrForPolledGamma:(NSString *)gamma primaries:(NSString *)primaries reason:(NSString *)reason force:(BOOL)force; -@end - -typedef CFDictionaryRef (*CoreDisplayCreateInfoDictionaryFn)(CGDirectDisplayID displayId); - -static void setBoolWithSelector(id target, NSString *selectorName, BOOL value) { - SEL selector = NSSelectorFromString(selectorName); - if (!target || !selector || ![target respondsToSelector:selector]) { - return; - } - typedef void (*Setter)(id, SEL, BOOL); - Setter setter = (Setter)[target methodForSelector:selector]; - setter(target, selector, value); -} - -static double doubleWithSelector(id target, NSString *selectorName, double fallback) { - SEL selector = NSSelectorFromString(selectorName); - if (!target || !selector || ![target respondsToSelector:selector]) { - return fallback; - } - typedef double (*Getter)(id, SEL); - Getter getter = (Getter)[target methodForSelector:selector]; - return getter(target, selector); -} - -static BOOL setLayerColorSpace(CALayer *layer, CGColorSpaceRef colorSpace) { - SEL selector = NSSelectorFromString(@"setColorspace:"); - if (!layer || !colorSpace || ![layer respondsToSelector:selector]) { - return NO; - } - typedef void (*Setter)(id, SEL, CGColorSpaceRef); - Setter setter = (Setter)[layer methodForSelector:selector]; - setter(layer, selector, colorSpace); - return YES; -} - -static BOOL setLayerEdrMetadata(CALayer *layer, double minNits, double maxNits, double opticalOutputScale) { - if (!layer || maxNits <= 0.0) { - return NO; - } - - Class edrMetadataClass = NSClassFromString(@"CAEDRMetadata"); - SEL selector = NSSelectorFromString(@"HDR10MetadataWithMinLuminance:maxLuminance:opticalOutputScale:"); - if (!edrMetadataClass || ![edrMetadataClass respondsToSelector:selector]) { - return NO; - } - - typedef id (*Factory)(id, SEL, float, float, float); - Factory factory = (Factory)[edrMetadataClass methodForSelector:selector]; - id metadata = factory(edrMetadataClass, selector, (float)minNits, (float)maxNits, (float)opticalOutputScale); - if (!metadata) { - return NO; - } - - SEL setterSelector = NSSelectorFromString(@"setEDRMetadata:"); - if (![layer respondsToSelector:setterSelector]) { - return NO; - } - typedef void (*Setter)(id, SEL, id); - Setter setter = (Setter)[layer methodForSelector:setterSelector]; - setter(layer, setterSelector, metadata); - return YES; -} - -static void clearLayerEdrMetadata(CALayer *layer) { - SEL selector = NSSelectorFromString(@"setEDRMetadata:"); - if (!layer || ![layer respondsToSelector:selector]) { - return; - } - typedef void (*Setter)(id, SEL, id); - Setter setter = (Setter)[layer methodForSelector:selector]; - setter(layer, selector, nil); -} - -static CGColorSpaceRef copyPqColorSpaceForPrimaries(NSString *primaries) { - NSString *normalized = [[primaries ?: @"" lowercaseString] stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; - CFStringRef name = [normalized isEqualToString:@"display-p3"] - ? kCGColorSpaceDisplayP3_PQ - : kCGColorSpaceITUR_2100_PQ; - return CGColorSpaceCreateWithName(name); -} - -static NSString *diagnosticSize(CGSize size) { - return [NSString stringWithFormat:@"%0.0fx%0.0f", size.width, size.height]; -} - -static NSString *diagnosticRect(NSRect rect) { - return [NSString stringWithFormat: - @"%0.0fx%0.0f@%0.0f,%0.0f", - rect.size.width, - rect.size.height, - rect.origin.x, - rect.origin.y - ]; -} - -static CGDirectDisplayID displayIdForScreen(NSScreen *screen) { - NSNumber *screenNumber = screen.deviceDescription[@"NSScreenNumber"]; - return screenNumber ? (CGDirectDisplayID)screenNumber.unsignedIntValue : CGMainDisplayID(); -} - -static double edrMaxForScreen(NSScreen *screen) { - NSScreen *targetScreen = screen ?: NSScreen.mainScreen; - double maxValue = doubleWithSelector( - targetScreen, - @"maximumPotentialExtendedDynamicRangeColorComponentValue", - 1.0 - ); - if (!std::isfinite(maxValue) || maxValue <= 0.0) { - return 1.0; - } - return maxValue; -} - -static CoreDisplayCreateInfoDictionaryFn coreDisplayInfoDictionaryFn(void) { - static BOOL didLookup = NO; - static CoreDisplayCreateInfoDictionaryFn fn = nullptr; - if (!didLookup) { - didLookup = YES; - void *handle = dlopen( - "/System/Library/Frameworks/CoreDisplay.framework/CoreDisplay", - RTLD_LAZY | RTLD_LOCAL - ); - fn = (CoreDisplayCreateInfoDictionaryFn)dlsym( - handle ?: RTLD_DEFAULT, - "CoreDisplay_DisplayCreateInfoDictionary" - ); - } - return fn; -} - -static double doubleFromCoreDisplayValue(CFTypeRef value) { - if (!value) { - return 0.0; - } - CFTypeID typeId = CFGetTypeID(value); - if (typeId == CFNumberGetTypeID()) { - double result = 0.0; - if (CFNumberGetValue((CFNumberRef)value, kCFNumberDoubleType, &result)) { - return result; - } - return 0.0; - } - if (typeId == CFDictionaryGetTypeID()) { - CFDictionaryRef dictionary = (CFDictionaryRef)value; - double directPeak = doubleFromCoreDisplayValue( - CFDictionaryGetValue(dictionary, CFSTR("NonReferencePeakHDRLuminance")) - ); - if (directPeak > 0.0) { - return directPeak; - } - return doubleFromCoreDisplayValue( - CFDictionaryGetValue(dictionary, CFSTR("DisplayBacklight")) - ); - } - return 0.0; -} - -static double coreDisplayPeakNitsForScreen(NSScreen *screen) { - CoreDisplayCreateInfoDictionaryFn fn = coreDisplayInfoDictionaryFn(); - if (!fn) { - return 0.0; - } - CFDictionaryRef info = fn(displayIdForScreen(screen ?: NSScreen.mainScreen)); - if (!info) { - return 0.0; - } - double peak = doubleFromCoreDisplayValue(info); - CFRelease(info); - if (!std::isfinite(peak) || peak < 100.0 || peak > 10000.0) { - return 0.0; - } - return peak; -} - -static double fixedPointNitsFromRegistryValue(CFTypeRef value) { - if (!value || CFGetTypeID(value) != CFNumberGetTypeID()) { - return 0.0; - } - double raw = 0.0; - if (!CFNumberGetValue((CFNumberRef)value, kCFNumberDoubleType, &raw) || raw <= 0.0) { - return 0.0; - } - double nits = raw / 65536.0; - if (!std::isfinite(nits) || nits < 100.0 || nits > 10000.0) { - return 0.0; - } - return nits; -} - -static double ioRegistryPeakNits(void) { - CFMutableDictionaryRef matching = IOServiceMatching("IOMobileFramebufferShim"); - if (!matching) { - return 0.0; - } - - io_iterator_t iterator = IO_OBJECT_NULL; - kern_return_t result = IOServiceGetMatchingServices(MACH_PORT_NULL, matching, &iterator); - if (result != KERN_SUCCESS || iterator == IO_OBJECT_NULL) { - return 0.0; - } - - double peak = 0.0; - io_object_t service = IO_OBJECT_NULL; - while ((service = IOIteratorNext(iterator)) != IO_OBJECT_NULL) { - CFTypeRef indicatorCap = IORegistryEntryCreateCFProperty( - service, - CFSTR("IOMFBIndicatorNitsCap"), - kCFAllocatorDefault, - 0 - ); - peak = fmax(peak, fixedPointNitsFromRegistryValue(indicatorCap)); - if (indicatorCap) { - CFRelease(indicatorCap); - } - - CFTypeRef rtplcCap = IORegistryEntryCreateCFProperty( - service, - CFSTR("RTPLCBLNitsCap"), - kCFAllocatorDefault, - 0 - ); - peak = fmax(peak, fixedPointNitsFromRegistryValue(rtplcCap)); - if (rtplcCap) { - CFRelease(rtplcCap); - } - - CFTypeRef backlightCap = IORegistryEntryCreateCFProperty( - service, - CFSTR("BLNitsCap"), - kCFAllocatorDefault, - 0 - ); - peak = fmax(peak, fixedPointNitsFromRegistryValue(backlightCap)); - if (backlightCap) { - CFRelease(backlightCap); - } - - IOObjectRelease(service); - } - IOObjectRelease(iterator); - return peak; -} - -static double hdrTargetPeakNitsForScreen(NSScreen *screen) { - double coreDisplayPeak = coreDisplayPeakNitsForScreen(screen); - if (coreDisplayPeak > 0.0) { - return coreDisplayPeak; - } - double registryPeak = ioRegistryPeakNits(); - if (registryPeak > 0.0) { - return registryPeak; - } - double edrMax = edrMaxForScreen(screen); - if (edrMax > 1.0) { - return fmin(fmax(edrMax * 100.0, 100.0), 10000.0); - } - return 100.0; -} - -static double hdrMetadataMaxNits(void) { - return 1000.0; -} - -static double normalizedHdrMetadataMaxNits(double targetPeakNits) { - if (std::isfinite(targetPeakNits) && targetPeakNits >= 100.0 && targetPeakNits <= 10000.0) { - return targetPeakNits; - } - return hdrMetadataMaxNits(); -} - -static CGLPixelFormatObj createPlayerOpenGLPixelFormat(GLint *bufferDepth) { - CGLPixelFormatObj pixelFormat = nullptr; - GLint pixelCount = 0; - CGLPixelFormatAttribute hdrAttributes[] = { - kCGLPFAOpenGLProfile, (CGLPixelFormatAttribute)kCGLOGLPVersion_3_2_Core, - kCGLPFAAccelerated, - kCGLPFADoubleBuffer, - kCGLPFAColorSize, (CGLPixelFormatAttribute)64, - kCGLPFAColorFloat, - kCGLPFANoRecovery, - kCGLPFAAllowOfflineRenderers, - (CGLPixelFormatAttribute)0 - }; - CGLError error = CGLChoosePixelFormat(hdrAttributes, &pixelFormat, &pixelCount); - if (error == kCGLNoError && pixelFormat) { - if (bufferDepth) { - *bufferDepth = 16; - } - return pixelFormat; - } - - CGLPixelFormatAttribute fallbackAttributes[] = { - kCGLPFAOpenGLProfile, (CGLPixelFormatAttribute)kCGLOGLPVersion_3_2_Core, - kCGLPFAAccelerated, - kCGLPFADoubleBuffer, - kCGLPFAColorSize, (CGLPixelFormatAttribute)32, - kCGLPFANoRecovery, - kCGLPFAAllowOfflineRenderers, - (CGLPixelFormatAttribute)0 - }; - pixelFormat = nullptr; - pixelCount = 0; - error = CGLChoosePixelFormat(fallbackAttributes, &pixelFormat, &pixelCount); - if (error == kCGLNoError && pixelFormat) { - if (bufferDepth) { - *bufferDepth = 8; - } - return pixelFormat; - } - return nullptr; -} - -static CGLContextObj createPlayerOpenGLContext(CGLPixelFormatObj pixelFormat) { - CGLContextObj context = nullptr; - if (!pixelFormat || CGLCreateContext(pixelFormat, nullptr, &context) != kCGLNoError || !context) { - return nullptr; - } - GLint swapInterval = 1; - CGLSetParameter(context, kCGLCPSwapInterval, &swapInterval); - CGLEnable(context, kCGLCEMPEngine); - return context; -} - -static void *mpvOpenGLGetProcAddress(void *ctx, const char *name) { - if (!name) { - return nullptr; - } - CFBundleRef framework = CFBundleGetBundleWithIdentifier(CFSTR("com.apple.opengl")); - if (!framework) { - return nullptr; - } - CFStringRef symbol = CFStringCreateWithCString(kCFAllocatorDefault, name, kCFStringEncodingASCII); - if (!symbol) { - return nullptr; - } - void *address = CFBundleGetFunctionPointerForName(framework, symbol); - CFRelease(symbol); - return address; -} - -@implementation NuvioMainThreadPriorityLock { - NSCondition *_condition; - BOOL _mainThreadNeedsLock; -} - -- (instancetype)init { - self = [super init]; - if (!self) { - return nil; - } - _condition = [NSCondition new]; - return self; -} - -- (void)beforeLocking { - [_condition lock]; - if ([NSThread isMainThread]) { - _mainThreadNeedsLock = YES; - } else { - while (_mainThreadNeedsLock) { - [_condition wait]; - } - } - [_condition unlock]; -} - -- (void)afterLocked { - if (![NSThread isMainThread]) { - return; - } - [_condition lock]; - _mainThreadNeedsLock = NO; - [_condition broadcast]; - [_condition unlock]; -} - -@end - -@implementation NuvioPlayerOpenGLLayer { - __weak PlayerMetalView *_owner; - CGLPixelFormatObj _pixelFormat; - CGLContextObj _glContext; - dispatch_queue_t _renderQueue; - NSRecursiveLock *_displayLock; - NuvioMainThreadPriorityLock *_mainThreadPriorityLock; - NSRecursiveLock *_renderLock; - mpv_render_context *_renderContext; - std::atomic_bool _needsFlip; - std::atomic_bool _forceDraw; - std::atomic_bool _renderQueued; -} - -@synthesize nuvioLiveResize = _nuvioLiveResize; -@synthesize bufferDepth = _bufferDepth; - -- (instancetype)initWithOwner:(PlayerMetalView *)owner { - self = [super init]; - if (!self) { - return nil; - } - _owner = owner; - _renderQueue = dispatch_queue_create("com.nuvio.player.mpvgl", DISPATCH_QUEUE_SERIAL); - dispatch_set_target_queue(_renderQueue, dispatch_get_global_queue(QOS_CLASS_USER_INTERACTIVE, 0)); - _displayLock = [NSRecursiveLock new]; - _mainThreadPriorityLock = [NuvioMainThreadPriorityLock new]; - _renderLock = [NSRecursiveLock new]; - _needsFlip.store(false); - _forceDraw.store(false); - _renderQueued.store(false); - _pixelFormat = createPlayerOpenGLPixelFormat(&_bufferDepth); - _glContext = createPlayerOpenGLContext(_pixelFormat); - self.autoresizingMask = kCALayerWidthSizable | kCALayerHeightSizable; - self.backgroundColor = NSColor.blackColor.CGColor; - self.opaque = YES; - self.asynchronous = NO; - self.needsDisplayOnBoundsChange = YES; - if (_bufferDepth > 8) { - self.contentsFormat = kCAContentsFormatRGBA16Float; - } - return self; -} - -- (void)dealloc { - if (_glContext) { - CGLReleaseContext(_glContext); - _glContext = nullptr; - } - if (_pixelFormat) { - CGLReleasePixelFormat(_pixelFormat); - _pixelFormat = nullptr; - } -} - -- (void)setNuvioLiveResize:(BOOL)nuvioLiveResize { - if (_nuvioLiveResize == nuvioLiveResize) { - return; - } - _nuvioLiveResize = nuvioLiveResize; - if (nuvioLiveResize) { - self.asynchronous = YES; - [self requestRenderForced:YES]; - } else { - self.asynchronous = NO; - } -} - -- (CGLPixelFormatObj)copyCGLPixelFormatForDisplayMask:(uint32_t)mask { - return _pixelFormat ? CGLRetainPixelFormat(_pixelFormat) : nullptr; -} - -- (CGLContextObj)copyCGLContextForPixelFormat:(CGLPixelFormatObj)pixelFormat { - return _glContext ? CGLRetainContext(_glContext) : nullptr; -} - -- (BOOL)canDrawInCGLContext:(CGLContextObj)ctx - pixelFormat:(CGLPixelFormatObj)pf - forLayerTime:(CFTimeInterval)t - displayTime:(const CVTimeStamp *)ts { - if (_nuvioLiveResize && [NSThread isMainThread]) { - return NO; - } - if (!_nuvioLiveResize) { - self.asynchronous = NO; - } - - return _forceDraw.load() || _needsFlip.load(); -} - -- (void)drawInCGLContext:(CGLContextObj)ctx - pixelFormat:(CGLPixelFormatObj)pf - forLayerTime:(CFTimeInterval)t - displayTime:(const CVTimeStamp *)ts { - [_renderLock lock]; - mpv_render_context *renderContext = _renderContext; - _needsFlip.store(false); - _forceDraw.store(false); - if (!renderContext) { - glClearColor(0.0, 0.0, 0.0, 1.0); - glClear(GL_COLOR_BUFFER_BIT); - glFlush(); - [_renderLock unlock]; - return; - } - - GLint framebuffer = 0; - glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &framebuffer); - CGFloat scale = self.contentsScale > 0.0 ? self.contentsScale : 1.0; - CGSize drawableSize = CGSizeMake( - llround(self.bounds.size.width * scale), - llround(self.bounds.size.height * scale) - ); - if (drawableSize.width <= 1.0 || drawableSize.height <= 1.0) { - glClearColor(0.0, 0.0, 0.0, 1.0); - glClear(GL_COLOR_BUFFER_BIT); - glFlush(); - [_renderLock unlock]; - return; - } - glViewport(0, 0, (GLsizei)drawableSize.width, (GLsizei)drawableSize.height); - mpv_opengl_fbo fbo = { - (int)framebuffer, - (int)drawableSize.width, - (int)drawableSize.height, - 0 - }; - int flipY = 1; - int depth = (int)_bufferDepth; - mpv_render_param params[] = { - { MPV_RENDER_PARAM_OPENGL_FBO, &fbo }, - { MPV_RENDER_PARAM_FLIP_Y, &flipY }, - { MPV_RENDER_PARAM_DEPTH, &depth }, - { MPV_RENDER_PARAM_INVALID, nullptr } - }; - mpv_render_context_render(renderContext, params); - mpv_render_context_report_swap(renderContext); - glFlush(); - [_renderLock unlock]; -} - -- (void)display { - [_mainThreadPriorityLock beforeLocking]; - [_displayLock lock]; - [_mainThreadPriorityLock afterLocked]; - if ([NSThread isMainThread]) { - [super display]; - } else { - [CATransaction begin]; - [super display]; - [CATransaction commit]; - } - [CATransaction flush]; - [_displayLock unlock]; -} - -- (void)setMpvRenderContext:(mpv_render_context *)renderContext { - [_renderLock lock]; - _renderContext = renderContext; - _forceDraw.store(true); - [_renderLock unlock]; - [self requestRenderForced:YES]; -} - -- (void)clearMpvRenderContext { - [_renderLock lock]; - _renderContext = nullptr; - _needsFlip.store(false); - _forceDraw.store(true); - [_renderLock unlock]; -} - -- (void)requestRender { - [self requestRenderForced:NO]; -} - -- (void)requestRenderForced:(BOOL)force { - if (force) { - _forceDraw.store(true); - } - _needsFlip.store(true); - bool expected = false; - if (!_renderQueued.compare_exchange_strong(expected, true)) { - return; - } - - dispatch_async(_renderQueue, ^{ - self->_renderQueued.store(false); - [self display]; - }); -} - -- (void)lockAndSetContext { - if (!_glContext) { - return; - } - CGLLockContext(_glContext); - CGLSetCurrentContext(_glContext); -} - -- (void)unlockContext { - if (!_glContext) { - return; - } - CGLUnlockContext(_glContext); -} - -@end - -static void mpvRenderUpdateCallback(void *callbackContext) { - PlayerMetalView *view = (__bridge PlayerMetalView *)callbackContext; - [view scheduleRenderUpdate]; -} - -@implementation PlayerMetalView { - NuvioPlayerOpenGLLayer *_openGLLayer; - mpv_render_context *_mpvRenderContext; - BOOL _hasConfiguredEdr; - BOOL _edrEnabled; - BOOL _fullscreenTransitionActive; - NSString *_edrPrimaries; - NSString *_lastAppliedEdrLayerKey; - double _edrMetadataMaxNits; -} - -- (instancetype)initWithFrame:(NSRect)frameRect { - self = [super initWithFrame:frameRect]; - if (!self) { - return nil; - } - - self.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; - self.wantsLayer = YES; - _openGLLayer = [[NuvioPlayerOpenGLLayer alloc] initWithOwner:self]; - _openGLLayer.frame = self.bounds; - _openGLLayer.contentsGravity = kCAGravityResize; - self.layer = _openGLLayer; - [self updateMetalLayerLayout]; - return self; -} - -- (BOOL)isOpaque { - return YES; -} - -- (CALayer *)makeBackingLayer { - return _openGLLayer ?: [[NuvioPlayerOpenGLLayer alloc] initWithOwner:self]; -} - -- (void)layout { - [super layout]; - [self updateMetalLayerLayout]; -} - -- (void)setFrameSize:(NSSize)newSize { - [super setFrameSize:newSize]; - [self updateMetalLayerLayout]; -} - -- (void)setBoundsSize:(NSSize)newSize { - [super setBoundsSize:newSize]; - [self updateMetalLayerLayout]; -} - -- (void)viewDidMoveToWindow { - [super viewDidMoveToWindow]; - [self updateMetalLayerLayout]; - setBoolWithSelector(self, @"setWantsExtendedDynamicRangeContent:", YES); - setBoolWithSelector(self.window, @"setWantsExtendedDynamicRangeContent:", YES); - setBoolWithSelector(self.window.contentView, @"setWantsExtendedDynamicRangeContent:", YES); - setBoolWithSelector(_openGLLayer, @"setWantsExtendedDynamicRangeContent:", YES); -} - -- (void)viewWillStartLiveResize { - [super viewWillStartLiveResize]; - _openGLLayer.nuvioLiveResize = YES; -} - -- (void)viewDidEndLiveResize { - [super viewDidEndLiveResize]; - // The bridge clears resize mode on its settle timer, after AppKit has - // finished the release/fullscreen layout burst. -} - -- (void)setFullscreenTransitionActive:(BOOL)active { - _fullscreenTransitionActive = active; - BOOL transitionLike = active || self.inLiveResize || self.window.inLiveResize; - _openGLLayer.nuvioLiveResize = transitionLike; - if (!transitionLike) { - [self updateMetalLayerLayout]; - [_openGLLayer requestRenderForced:YES]; - } -} - -- (void)updateMetalLayerLayout { - if (!_openGLLayer) { - return; - } - - NSSize boundsSize = self.bounds.size; - if (boundsSize.width <= 1.0 || boundsSize.height <= 1.0) { - return; - } - - CGFloat scale = self.window.backingScaleFactor > 0.0 - ? self.window.backingScaleFactor - : NSScreen.mainScreen.backingScaleFactor; - BOOL liveResize = self.inLiveResize || self.window.inLiveResize || _fullscreenTransitionActive; - [CATransaction begin]; - [CATransaction setDisableActions:YES]; - _openGLLayer.nuvioLiveResize = liveResize; - _openGLLayer.contentsScale = scale; - _openGLLayer.frame = self.bounds; - if (!liveResize) { - [self refreshMetalLayerEdrState]; - [_openGLLayer requestRenderForced:NO]; - } - [CATransaction commit]; -} - -- (void)refreshMetalLayerEdrState { - if (!_hasConfiguredEdr || !_openGLLayer) { - return; - } - NSString *stateKey = [NSString stringWithFormat: - @"%@:%@:%0.0f", - _edrEnabled ? @"hdr" : @"sdr", - _edrPrimaries ?: @"auto", - normalizedHdrMetadataMaxNits(_edrMetadataMaxNits) - ]; - if (_lastAppliedEdrLayerKey && [_lastAppliedEdrLayerKey isEqualToString:stateKey]) { - return; - } - - setBoolWithSelector(self, @"setWantsExtendedDynamicRangeContent:", YES); - setBoolWithSelector(self.window, @"setWantsExtendedDynamicRangeContent:", YES); - setBoolWithSelector(self.window.contentView, @"setWantsExtendedDynamicRangeContent:", YES); - setBoolWithSelector(_openGLLayer, @"setWantsExtendedDynamicRangeContent:", YES); - - CGColorSpaceRef colorSpace = _edrEnabled - ? copyPqColorSpaceForPrimaries(_edrPrimaries) - : CGColorSpaceCreateWithName(kCGColorSpaceSRGB); - if (colorSpace) { - setLayerColorSpace(_openGLLayer, colorSpace); - CGColorSpaceRelease(colorSpace); - } - - if (_edrEnabled) { - setLayerEdrMetadata(_openGLLayer, 0.0, normalizedHdrMetadataMaxNits(_edrMetadataMaxNits), 1.0); - } else { - clearLayerEdrMetadata(_openGLLayer); - } - _lastAppliedEdrLayerKey = [stateKey copy]; - [_openGLLayer requestRender]; -} - -- (void)configureExtendedDynamicRange:(BOOL)enabled primaries:(NSString *)primaries targetPeakNits:(double)targetPeakNits { - _hasConfiguredEdr = YES; - _edrEnabled = enabled; - _edrPrimaries = [primaries copy]; - _edrMetadataMaxNits = enabled ? normalizedHdrMetadataMaxNits(targetPeakNits) : hdrMetadataMaxNits(); - _lastAppliedEdrLayerKey = nil; - - setBoolWithSelector(self, @"setWantsExtendedDynamicRangeContent:", YES); - setBoolWithSelector(self.window, @"setWantsExtendedDynamicRangeContent:", YES); - setBoolWithSelector(self.window.contentView, @"setWantsExtendedDynamicRangeContent:", YES); - setBoolWithSelector(_openGLLayer, @"setWantsExtendedDynamicRangeContent:", YES); - - CGColorSpaceRef colorSpace = enabled - ? copyPqColorSpaceForPrimaries(primaries) - : CGColorSpaceCreateWithName(kCGColorSpaceSRGB); - if (colorSpace) { - setLayerColorSpace(_openGLLayer, colorSpace); - } - if (colorSpace) { - CGColorSpaceRelease(colorSpace); - } - - if (enabled) { - setLayerEdrMetadata(_openGLLayer, 0.0, _edrMetadataMaxNits, 1.0); - } else { - clearLayerEdrMetadata(_openGLLayer); - } - _lastAppliedEdrLayerKey = [[NSString stringWithFormat: - @"%@:%@:%0.0f", - enabled ? @"hdr" : @"sdr", - primaries ?: @"auto", - _edrMetadataMaxNits - ] copy]; - [_openGLLayer requestRender]; -} - -- (BOOL)createMpvRenderContext:(mpv_handle *)mpv error:(NSString **)error { - if (_mpvRenderContext) { - return YES; - } - if (!_openGLLayer) { - if (error) { - *error = @"OpenGL layer unavailable"; - } - return NO; - } - - [_openGLLayer lockAndSetContext]; - const char *apiType = MPV_RENDER_API_TYPE_OPENGL; - mpv_opengl_init_params initParams = { - mpvOpenGLGetProcAddress, - nullptr - }; - mpv_render_param params[] = { - { MPV_RENDER_PARAM_API_TYPE, (void *)apiType }, - { MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, &initParams }, - { MPV_RENDER_PARAM_INVALID, nullptr } - }; - int result = mpv_render_context_create(&_mpvRenderContext, mpv, params); - [_openGLLayer unlockContext]; - if (result < 0) { - if (error) { - *error = [NSString stringWithFormat:@"mpv_render_context_create failed: %s", mpv_error_string(result)]; - } - return NO; - } - - [_openGLLayer setMpvRenderContext:_mpvRenderContext]; - mpv_render_context_set_update_callback( - _mpvRenderContext, - mpvRenderUpdateCallback, - (__bridge void *)self - ); - return YES; -} - -- (void)destroyMpvRenderContext { - if (!_mpvRenderContext) { - return; - } - mpv_render_context *renderContext = _mpvRenderContext; - _mpvRenderContext = nullptr; - mpv_render_context_set_update_callback(renderContext, nullptr, nullptr); - [_openGLLayer clearMpvRenderContext]; - [_openGLLayer lockAndSetContext]; - mpv_render_context_free(renderContext); - [_openGLLayer unlockContext]; -} - -- (void)scheduleRenderUpdate { - [_openGLLayer requestRender]; -} - -- (double)edrMax { - return edrMaxForScreen(self.window.screen ?: NSScreen.mainScreen); -} - -- (double)hdrTargetPeakNits { - return hdrTargetPeakNitsForScreen(self.window.screen ?: NSScreen.mainScreen); -} - -- (CGSize)drawableSize { - if (!_openGLLayer) { - return CGSizeZero; - } - CGFloat scale = _openGLLayer.contentsScale > 0.0 ? _openGLLayer.contentsScale : 1.0; - return CGSizeMake( - llround(_openGLLayer.bounds.size.width * scale), - llround(_openGLLayer.bounds.size.height * scale) - ); -} - -@end - -@implementation PlayerScriptHandler -- (void)userContentController:(WKUserContentController *)userContentController - didReceiveScriptMessage:(WKScriptMessage *)message { - if ([message.body isKindOfClass:[NSDictionary class]]) { - [self.player handleScriptMessage:(NSDictionary *)message.body]; - } -} -@end - -static NSString *javaScriptStringLiteral(NSString *value) { - NSArray *array = @[value ?: @""]; - NSError *error = nil; - NSData *data = [NSJSONSerialization dataWithJSONObject:array options:0 error:&error]; - if (!data) { - return @"\"\""; - } - NSString *jsonArray = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; - if (jsonArray.length < 2) { - return @"\"\""; - } - return [jsonArray substringWithRange:NSMakeRange(1, jsonArray.length - 2)]; -} - -static void setMpvOptionString(mpv_handle *mpv, const char *name, const char *value) { - mpv_set_option_string(mpv, name, value); -} - -@implementation MpvWebPlayer { - NSView *_hostView; - PlayerMetalView *_videoView; - WKWebView *_webView; - PlayerScriptHandler *_scriptHandler; - mpv_handle *_mpv; - NSTimer *_timer; - NSTimer *_resizeSettleTimer; - NSTimer *_fullscreenTransitionTimer; - JavaVM *_javaVm; - jobject _eventSink; - jmethodID _eventMethod; - NSString *_lastConfiguredHdrKey; - NSString *_lastResizeRefreshKey; - dispatch_queue_t _mpvEventQueue; - BOOL _didFocusControlsWebView; - BOOL _controlsWebReady; - BOOL _fullscreenTransitionActive; - NSString *_pendingControlsJson; - double _initialStartSeconds; - BOOL _controlsSyncInFlight; - NSRect _lastAppliedNativeLayoutBounds; - BOOL _lastAppliedNativeLayoutWasLiveResize; - NSTimeInterval _lightweightResizeSettleUntil; - NSSize _lastControlsViewportNudgeSize; - NSTimeInterval _lastControlsViewportNudgeAt; - std::atomic _cachedDurationSeconds; - std::atomic _cachedPositionSeconds; - std::atomic _cachedCacheAheadSeconds; - std::atomic _cachedSpeed; - std::atomic_bool _cachedPaused; - std::atomic_bool _cachedLoading; - std::atomic_bool _cachedEnded; -} - -- (instancetype)initWithHostView:(NSView *)hostView - sourceUrl:(NSString *)sourceUrl - headerLines:(NSArray *)headerLines - playWhenReady:(BOOL)playWhenReady - initialPositionMs:(long long)initialPositionMs - controlsUrl:(NSString *)controlsUrl - javaVm:(JavaVM *)javaVm - eventSink:(jobject)eventSink - eventMethod:(jmethodID)eventMethod { - self = [super init]; - if (!self) { - return nil; - } - - _cachedDurationSeconds.store(0.0); - _cachedPositionSeconds.store(initialPositionMs > 0 ? (double)initialPositionMs / 1000.0 : 0.0); - _cachedCacheAheadSeconds.store(0.0); - _cachedSpeed.store(1.0); - _cachedPaused.store(!playWhenReady); - _cachedLoading.store(true); - _cachedEnded.store(false); - _mpvEventQueue = dispatch_queue_create("com.nuvio.desktop.mpv-events", DISPATCH_QUEUE_SERIAL); - _javaVm = javaVm; - _eventSink = eventSink; - _eventMethod = eventMethod; - - _hostView = hostView; - _hostView.wantsLayer = YES; - _hostView.layer.backgroundColor = NSColor.blackColor.CGColor; - [_hostView setPostsFrameChangedNotifications:YES]; - [_hostView setPostsBoundsChangedNotifications:YES]; - - _videoView = [[PlayerMetalView alloc] initWithFrame:_hostView.bounds]; - [_hostView addSubview:_videoView]; - - _scriptHandler = [PlayerScriptHandler new]; - _scriptHandler.player = self; - WKUserContentController *contentController = [WKUserContentController new]; - [contentController addScriptMessageHandler:_scriptHandler name:@"player"]; - - WKWebViewConfiguration *configuration = [WKWebViewConfiguration new]; - configuration.userContentController = contentController; - _webView = [[WKWebView alloc] initWithFrame:_hostView.bounds configuration:configuration]; - _webView.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; - _webView.wantsLayer = YES; - [_webView setValue:@NO forKey:@"drawsBackground"]; - [_hostView addSubview:_webView positioned:NSWindowAbove relativeTo:_videoView]; - NSURL *controlsURL = [NSURL URLWithString:controlsUrl ?: @""]; - if (!controlsURL) { - @throw [NSException exceptionWithName:@"PlayerBridgeError" - reason:@"Invalid native player controls URL." - userInfo:nil]; - } - if (controlsURL.isFileURL) { - [_webView loadFileURL:controlsURL allowingReadAccessToURL:[controlsURL URLByDeletingLastPathComponent]]; - } else { - [_webView loadRequest:[NSURLRequest requestWithURL:controlsURL]]; - } - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(hostViewFrameDidChange:) - name:NSViewFrameDidChangeNotification - object:_hostView]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(hostViewBoundsDidChange:) - name:NSViewBoundsDidChangeNotification - object:_hostView]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(windowWillEnterFullScreen:) - name:NSWindowWillEnterFullScreenNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(windowDidEnterFullScreen:) - name:NSWindowDidEnterFullScreenNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(windowWillExitFullScreen:) - name:NSWindowWillExitFullScreenNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(windowDidExitFullScreen:) - name:NSWindowDidExitFullScreenNotification - object:nil]; - [self layoutNativeSubviews]; - dispatch_async(dispatch_get_main_queue(), ^{ - [self focusControlsWebViewIfNeeded]; - }); - - [self startMpvWithSource:sourceUrl - headerLines:headerLines - playWhenReady:playWhenReady - initialPositionMs:initialPositionMs]; - _timer = [NSTimer scheduledTimerWithTimeInterval:0.5 - target:self - selector:@selector(syncControls) - userInfo:nil - repeats:YES]; - return self; -} - -- (void)focusControlsWebViewIfNeeded { - if (_didFocusControlsWebView || !_webView || !_webView.window) { - return; - } - _didFocusControlsWebView = YES; - [_webView.window makeFirstResponder:_webView]; -} - -- (void)layoutControlsWebViewToBounds:(NSRect)bounds immediate:(BOOL)immediate { - if (!_webView) { - return; - } - - if (!NSEqualRects(_webView.frame, bounds)) { - _webView.frame = bounds; - } - if (!immediate) { - return; - } - - [_webView setNeedsLayout:YES]; - [_webView layoutSubtreeIfNeeded]; - [_webView setNeedsDisplay:YES]; - - NSTimeInterval now = NSDate.timeIntervalSinceReferenceDate; - BOOL sizeChanged = !NSEqualSizes(_lastControlsViewportNudgeSize, bounds.size); - if (!_controlsWebReady || !sizeChanged || now - _lastControlsViewportNudgeAt < 0.05) { - return; - } - - _lastControlsViewportNudgeSize = bounds.size; - _lastControlsViewportNudgeAt = now; - NSString *script = @"window.nuvioNativeViewportChanged ? window.nuvioNativeViewportChanged() : window.dispatchEvent(new Event('resize'));"; - [_webView evaluateJavaScript:script completionHandler:nil]; -} - -- (void)layoutNativeSubviews { - if (!_hostView) { - return; - } - NSRect bounds = _hostView.bounds; - if (bounds.size.width <= 0.0 || bounds.size.height <= 0.0) { - return; - } - - NSTimeInterval now = NSDate.timeIntervalSinceReferenceDate; - BOOL nativeLiveResize = _hostView.inLiveResize || _hostView.window.inLiveResize; - BOOL wasResizeLikeLayout = _lastAppliedNativeLayoutWasLiveResize - || _fullscreenTransitionActive - || (_lightweightResizeSettleUntil > now); - BOOL hasPreviousBounds = _lastAppliedNativeLayoutBounds.size.width > 1.0 - && _lastAppliedNativeLayoutBounds.size.height > 1.0; - if (!_fullscreenTransitionActive && !nativeLiveResize && hasPreviousBounds && !NSEqualRects(bounds, _lastAppliedNativeLayoutBounds)) { - double previousArea = _lastAppliedNativeLayoutBounds.size.width * _lastAppliedNativeLayoutBounds.size.height; - double currentArea = bounds.size.width * bounds.size.height; - double areaRatio = previousArea > 1.0 ? currentArea / previousArea : 1.0; - CGFloat widthDelta = fabs(bounds.size.width - _lastAppliedNativeLayoutBounds.size.width); - CGFloat heightDelta = fabs(bounds.size.height - _lastAppliedNativeLayoutBounds.size.height); - if (areaRatio > 1.75 || areaRatio < 0.60 || widthDelta > 480.0 || heightDelta > 320.0) { - [self activateFullscreenTransitionWithReason:@"layout-jump"]; - } - } - - BOOL liveResize = nativeLiveResize || _fullscreenTransitionActive; - BOOL settlingFromResize = !liveResize && wasResizeLikeLayout; - if (liveResize - && _lastAppliedNativeLayoutWasLiveResize - && NSEqualRects(bounds, _lastAppliedNativeLayoutBounds)) { - return; - } - if (liveResize || settlingFromResize) { - _lightweightResizeSettleUntil = now + 0.45; - } - _lastAppliedNativeLayoutBounds = bounds; - _lastAppliedNativeLayoutWasLiveResize = liveResize; - - [CATransaction begin]; - [CATransaction setDisableActions:YES]; - if (_videoView) { - if (liveResize || settlingFromResize) { - [_videoView setFullscreenTransitionActive:YES]; - } - if (!NSEqualRects(_videoView.frame, bounds)) { - _videoView.frame = bounds; - } - if (!liveResize && !settlingFromResize) { - [_videoView setNeedsLayout:YES]; - [_videoView layoutSubtreeIfNeeded]; - [_videoView updateMetalLayerLayout]; - } - } - if (_webView) { - BOOL immediateControlsLayout = _fullscreenTransitionActive || settlingFromResize || (!liveResize && !settlingFromResize); - [self layoutControlsWebViewToBounds:bounds immediate:immediateControlsLayout]; - } - [CATransaction commit]; - - if (liveResize || settlingFromResize) { - if (_mpv) { - [self schedulePostResizeRefreshWithReason:liveResize ? @"live-layout" : @"settle-layout"]; - } - return; - } - - CGSize currentDrawableSize = _videoView ? [_videoView drawableSize] : CGSizeZero; - NSString *resizeKey = [NSString stringWithFormat: - @"%@:%@:%@:%@", - diagnosticRect(bounds), - _videoView ? diagnosticRect(_videoView.frame) : @"none", - _webView ? diagnosticRect(_webView.frame) : @"none", - diagnosticSize(currentDrawableSize) - ]; - if (!_lastResizeRefreshKey || ![_lastResizeRefreshKey isEqualToString:resizeKey]) { - _lastResizeRefreshKey = resizeKey; - if (_mpv) { - [self schedulePostResizeRefreshWithReason:@"layout"]; - } - } -} - -- (void)hostViewFrameDidChange:(NSNotification *)notification { - [self layoutNativeSubviews]; -} - -- (void)hostViewBoundsDidChange:(NSNotification *)notification { - [self layoutNativeSubviews]; -} - -- (BOOL)notificationBelongsToHostWindow:(NSNotification *)notification { - NSWindow *window = [notification.object isKindOfClass:[NSWindow class]] ? (NSWindow *)notification.object : nil; - return window && _hostView.window && window == _hostView.window; -} - -- (void)windowWillEnterFullScreen:(NSNotification *)notification { - if (![self notificationBelongsToHostWindow:notification]) { - return; - } - [self beginFullscreenTransitionWithReason:@"will-enter-fullscreen"]; -} - -- (void)windowDidEnterFullScreen:(NSNotification *)notification { - if (![self notificationBelongsToHostWindow:notification]) { - return; - } - [self finishFullscreenTransitionWithReason:@"did-enter-fullscreen"]; -} - -- (void)windowWillExitFullScreen:(NSNotification *)notification { - if (![self notificationBelongsToHostWindow:notification]) { - return; - } - [self beginFullscreenTransitionWithReason:@"will-exit-fullscreen"]; -} - -- (void)windowDidExitFullScreen:(NSNotification *)notification { - if (![self notificationBelongsToHostWindow:notification]) { - return; - } - [self finishFullscreenTransitionWithReason:@"did-exit-fullscreen"]; -} - -- (void)activateFullscreenTransitionWithReason:(NSString *)reason { - _fullscreenTransitionActive = YES; - [_fullscreenTransitionTimer invalidate]; - [_videoView setFullscreenTransitionActive:YES]; - _fullscreenTransitionTimer = [NSTimer scheduledTimerWithTimeInterval:1.25 - target:self - selector:@selector(handleFullscreenTransitionTimer:) - userInfo:reason ?: @"unknown" - repeats:NO]; -} - -- (void)beginFullscreenTransitionWithReason:(NSString *)reason { - [self activateFullscreenTransitionWithReason:reason]; - [self layoutNativeSubviews]; -} - -- (void)finishFullscreenTransitionWithReason:(NSString *)reason { - if (!_fullscreenTransitionActive && !_fullscreenTransitionTimer) { - return; - } - [_fullscreenTransitionTimer invalidate]; - _fullscreenTransitionTimer = nil; - _fullscreenTransitionActive = NO; - [self layoutNativeSubviews]; - [_resizeSettleTimer invalidate]; - _resizeSettleTimer = nil; - if (_mpv) { - NSString *refreshReason = [NSString stringWithFormat:@"fullscreen/%@", reason ?: @"unknown"]; - [self schedulePostResizeRefreshWithReason:refreshReason]; - } -} - -- (void)handleFullscreenTransitionTimer:(NSTimer *)timer { - [self finishFullscreenTransitionWithReason:[NSString stringWithFormat:@"timer/%@", timer.userInfo ?: @"unknown"]]; -} - -- (void)schedulePostResizeRefreshWithReason:(NSString *)reason { - if (!_mpv || !_videoView) { - return; - } - [_resizeSettleTimer invalidate]; - _resizeSettleTimer = [NSTimer scheduledTimerWithTimeInterval:0.35 - target:self - selector:@selector(handleResizeSettleTimer:) - userInfo:reason ?: @"unknown" - repeats:NO]; -} - -- (void)handleResizeSettleTimer:(NSTimer *)timer { - _resizeSettleTimer = nil; - if (!_mpv || !_videoView) { - return; - } - if (_hostView.inLiveResize || _hostView.window.inLiveResize || _fullscreenTransitionActive) { - [self schedulePostResizeRefreshWithReason:timer.userInfo ?: @"unknown"]; - return; - } - _lightweightResizeSettleUntil = 0.0; - [self layoutControlsWebViewToBounds:_hostView.bounds immediate:YES]; - [_videoView setFullscreenTransitionActive:NO]; - [_videoView updateMetalLayerLayout]; - NSString *reason = [NSString stringWithFormat:@"resize-settled/%@", timer.userInfo ?: @"unknown"]; - [self configureHdrForCurrentScreenWithReason:reason force:NO]; -} - -- (void)startMpvWithSource:(NSString *)sourceUrl - headerLines:(NSArray *)headerLines - playWhenReady:(BOOL)playWhenReady - initialPositionMs:(long long)initialPositionMs { - _mpv = mpv_create(); - if (!_mpv) { - @throw [NSException exceptionWithName:@"PlayerBridgeError" - reason:@"mpv_create failed" - userInfo:nil]; - } - _initialStartSeconds = initialPositionMs > 0 ? (double)initialPositionMs / 1000.0 : 0.0; - - setMpvOptionString(_mpv, "config", "no"); - setMpvOptionString(_mpv, "osc", "no"); - setMpvOptionString(_mpv, "input-default-bindings", "yes"); - setMpvOptionString(_mpv, "input-vo-keyboard", "no"); - setMpvOptionString(_mpv, "keep-open", "yes"); - setMpvOptionString(_mpv, "vo", "libmpv"); - setMpvOptionString(_mpv, "hwdec", "auto"); - setMpvOptionString(_mpv, "gpu-hwdec-interop", "auto"); - setMpvOptionString(_mpv, "hwdec-codecs", "all"); - setMpvOptionString(_mpv, "vd-lavc-software-fallback", "yes"); - setMpvOptionString(_mpv, "vd-lavc-threads", "4"); - setMpvOptionString(_mpv, "target-colorspace-hint", "yes"); - setMpvOptionString(_mpv, "target-colorspace-hint-mode", "source"); - setMpvOptionString(_mpv, "target-colorspace-hint-strict", "no"); - setMpvOptionString(_mpv, "tone-mapping", "auto"); - setMpvOptionString(_mpv, "hdr-compute-peak", "no"); - setMpvOptionString(_mpv, "dither-depth", "auto"); - setMpvOptionString(_mpv, "deband", "yes"); - setMpvOptionString(_mpv, "scale", "spline36"); - setMpvOptionString(_mpv, "cscale", "spline36"); - setMpvOptionString(_mpv, "demuxer-max-bytes", "64MiB"); - setMpvOptionString(_mpv, "demuxer-max-back-bytes", "16MiB"); - setMpvOptionString(_mpv, "demuxer-seekable-cache", "no"); - setMpvOptionString(_mpv, "cache-secs", "30"); - setMpvOptionString(_mpv, "hr-seek", "no"); - - if (headerLines.count > 0) { - NSString *headers = [headerLines componentsJoinedByString:@","]; - setMpvOptionString(_mpv, "http-header-fields", headers.UTF8String); - } - - int initResult = mpv_initialize(_mpv); - if (initResult < 0) { - NSString *reason = [NSString stringWithFormat:@"mpv_initialize failed: %s", mpv_error_string(initResult)]; - @throw [NSException exceptionWithName:@"PlayerBridgeError" reason:reason userInfo:nil]; - } - - NSString *renderError = nil; - if (![_videoView createMpvRenderContext:_mpv error:&renderError]) { - NSString *reason = renderError ?: @"mpv render context failed"; - @throw [NSException exceptionWithName:@"PlayerBridgeError" reason:reason userInfo:nil]; - } - - std::vector command = {"loadfile", sourceUrl.UTF8String}; - std::string loadOptions; - if (initialPositionMs > 0) { - char startBuffer[64]; - snprintf(startBuffer, sizeof(startBuffer), "start=%.3f", (double)initialPositionMs / 1000.0); - loadOptions = startBuffer; - command.push_back("replace"); - command.push_back("-1"); - command.push_back(loadOptions.c_str()); - } - command.push_back(NULL); - int commandResult = mpv_command(_mpv, command.data()); - if (commandResult < 0) { - NSString *reason = [NSString stringWithFormat:@"mpv loadfile failed: %s", mpv_error_string(commandResult)]; - @throw [NSException exceptionWithName:@"PlayerBridgeError" reason:reason userInfo:nil]; - } - - [self setPaused:!playWhenReady]; -} - -- (void)syncControls { - if (!_webView || !_mpv) { - return; - } - [self layoutNativeSubviews]; - [self focusControlsWebViewIfNeeded]; - if (_controlsSyncInFlight || !_mpvEventQueue) { - return; - } - _controlsSyncInFlight = YES; - - mpv_handle *mpv = _mpv; - dispatch_async(_mpvEventQueue, ^{ - @autoreleasepool { - if (self->_mpv != mpv) { - dispatch_async(dispatch_get_main_queue(), ^{ - self->_controlsSyncInFlight = NO; - }); - return; - } - - double duration = [self doubleProperty:"duration" fallback:0.0]; - double position = [self doubleProperty:"time-pos" fallback:0.0]; - double cacheAhead = [self cacheAheadSecondsForPosition:position]; - BOOL paused = [self rawIsPaused]; - BOOL ended = [self rawIsEnded]; - BOOL loading = [self rawLoadingWithPaused:paused ended:ended duration:duration]; - double speed = [self rawSpeed]; - NSString *audioTracks = [self audioTracksJson] ?: @"[]"; - NSString *subtitleTracks = [self subtitleTracksJson] ?: @"[]"; - NSString *gamma = [[self stringProperty:"video-params/gamma" fallback:@""] lowercaseString]; - NSString *primaries = [[self stringProperty:"video-params/primaries" fallback:@""] lowercaseString]; - [self updateCachedDuration:duration - position:position - cacheAhead:cacheAhead - paused:paused - loading:loading - ended:ended - speed:speed]; - - dispatch_async(dispatch_get_main_queue(), ^{ - self->_controlsSyncInFlight = NO; - if (!self->_webView || self->_mpv != mpv) { - return; - } - [self applyHdrForPolledGamma:gamma primaries:primaries reason:@"sync" force:NO]; - NSString *script = [NSString stringWithFormat: - @"window.playerUpdate({duration:%0.3f,position:%0.3f,paused:%@,loading:%@,audioTracks:%@,subtitleTracks:%@})", - duration, - position, - paused ? @"true" : @"false", - loading ? @"true" : @"false", - audioTracks, - subtitleTracks]; - [self->_webView evaluateJavaScript:script completionHandler:nil]; - }); - } - }); -} - -- (void)configureHdrForCurrentScreenIfNeeded { - [self configureHdrForCurrentScreenWithReason:@"legacy" force:NO]; -} - -- (void)configureHdrForCurrentScreenWithReason:(NSString *)reason force:(BOOL)force { - if (!_mpv || !_videoView) { - return; - } - NSString *gamma = [[self stringProperty:"video-params/gamma" fallback:@""] lowercaseString]; - NSString *primaries = [[self stringProperty:"video-params/primaries" fallback:@""] lowercaseString]; - if (gamma.length == 0 && primaries.length == 0) { - return; - } - - BOOL isPq = [gamma containsString:@"pq"] - || [gamma containsString:@"2084"] - || [gamma containsString:@"st2084"]; - BOOL isHlg = [gamma containsString:@"hlg"]; - double edrMax = [_videoView edrMax]; - double screenPeak = [_videoView hdrTargetPeakNits]; - BOOL hdrDetected = (isPq || isHlg) && edrMax > 1.0; - NSString *targetPrimaries = hdrDetected && primaries.length > 0 ? primaries : @"auto"; - NSString *stateKey = [NSString stringWithFormat: - @"%@:%@:%@:%0.2f:%0.0f", - hdrDetected ? @"native-opengl-edr" : @"sdr", - gamma ?: @"", - targetPrimaries, - edrMax, - screenPeak - ]; - if (!force && _lastConfiguredHdrKey && [_lastConfiguredHdrKey isEqualToString:stateKey]) { - return; - } - _lastConfiguredHdrKey = stateKey; - - double targetPeakNits = normalizedHdrMetadataMaxNits(screenPeak); - [_videoView configureExtendedDynamicRange:hdrDetected primaries:targetPrimaries targetPeakNits:targetPeakNits]; - NSString *targetTransfer = hdrDetected ? @"pq" : @"auto"; - NSString *toneMapping = hdrDetected ? @"" : @"auto"; - NSString *targetPeak = hdrDetected ? [NSString stringWithFormat:@"%.0f", targetPeakNits] : @"auto"; - [self setStringProperty:"target-colorspace-hint" value:@"yes"]; - [self setStringProperty:"target-colorspace-hint-mode" value:hdrDetected ? @"source" : @"target"]; - [self setStringProperty:"target-colorspace-hint-strict" value:@"no"]; - [self setStringProperty:"tone-mapping" value:toneMapping]; - [self setStringProperty:"hdr-compute-peak" value:@"no"]; - [self setStringProperty:"target-prim" value:targetPrimaries]; - [self setStringProperty:"target-trc" value:targetTransfer]; - [self setStringProperty:"target-peak" value:targetPeak]; -} - -- (void)applyHdrForPolledGamma:(NSString *)gamma primaries:(NSString *)primaries reason:(NSString *)reason force:(BOOL)force { - if (!_mpv || !_videoView) { - return; - } - NSString *normalizedGamma = [[gamma ?: @"" lowercaseString] stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; - NSString *normalizedPrimaries = [[primaries ?: @"" lowercaseString] stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; - if (normalizedGamma.length == 0 && normalizedPrimaries.length == 0) { - return; - } - - BOOL isPq = [normalizedGamma containsString:@"pq"] - || [normalizedGamma containsString:@"2084"] - || [normalizedGamma containsString:@"st2084"]; - BOOL isHlg = [normalizedGamma containsString:@"hlg"]; - double edrMax = [_videoView edrMax]; - double screenPeak = [_videoView hdrTargetPeakNits]; - BOOL hdrDetected = (isPq || isHlg) && edrMax > 1.0; - NSString *targetPrimaries = hdrDetected && normalizedPrimaries.length > 0 ? normalizedPrimaries : @"auto"; - NSString *stateKey = [NSString stringWithFormat: - @"%@:%@:%@:%0.2f:%0.0f", - hdrDetected ? @"native-opengl-edr" : @"sdr", - normalizedGamma ?: @"", - targetPrimaries, - edrMax, - screenPeak - ]; - if (!force && _lastConfiguredHdrKey && [_lastConfiguredHdrKey isEqualToString:stateKey]) { - return; - } - _lastConfiguredHdrKey = stateKey; - - double targetPeakNits = normalizedHdrMetadataMaxNits(screenPeak); - [_videoView configureExtendedDynamicRange:hdrDetected primaries:targetPrimaries targetPeakNits:targetPeakNits]; - - NSString *targetTransfer = hdrDetected ? @"pq" : @"auto"; - NSString *toneMapping = hdrDetected ? @"" : @"auto"; - NSString *targetPeak = hdrDetected ? [NSString stringWithFormat:@"%.0f", targetPeakNits] : @"auto"; - mpv_handle *mpv = _mpv; - dispatch_queue_t queue = _mpvEventQueue; - if (queue) { - dispatch_async(queue, ^{ - if (self->_mpv != mpv) { - return; - } - mpv_set_property_string(mpv, "target-colorspace-hint", "yes"); - mpv_set_property_string(mpv, "target-colorspace-hint-mode", hdrDetected ? "source" : "target"); - mpv_set_property_string(mpv, "target-colorspace-hint-strict", "no"); - mpv_set_property_string(mpv, "tone-mapping", toneMapping.UTF8String); - mpv_set_property_string(mpv, "hdr-compute-peak", "no"); - mpv_set_property_string(mpv, "target-prim", targetPrimaries.UTF8String); - mpv_set_property_string(mpv, "target-trc", targetTransfer.UTF8String); - mpv_set_property_string(mpv, "target-peak", targetPeak.UTF8String); - }); - } -} - -- (JNIEnv *)jniEnvDidAttach:(BOOL *)didAttach { - if (didAttach) { - *didAttach = NO; - } - if (!_javaVm) { - return nullptr; - } - - JNIEnv *env = nullptr; - jint status = _javaVm->GetEnv((void **)&env, JNI_VERSION_1_6); - if (status == JNI_OK) { - return env; - } - if (status != JNI_EDETACHED) { - return nullptr; - } - if (_javaVm->AttachCurrentThread((void **)&env, nullptr) != JNI_OK) { - return nullptr; - } - if (didAttach) { - *didAttach = YES; - } - return env; -} - -- (void)sendPlayerEvent:(NSString *)type value:(double)value { - if (!_eventSink || !_eventMethod) { - return; - } - - BOOL didAttach = NO; - JNIEnv *env = [self jniEnvDidAttach:&didAttach]; - if (!env) { - return; - } - - jstring eventType = env->NewStringUTF(type.UTF8String); - env->CallVoidMethod(_eventSink, _eventMethod, eventType, (jdouble)value); - if (env->ExceptionCheck()) { - env->ExceptionClear(); - } - if (eventType) { - env->DeleteLocalRef(eventType); - } - if (didAttach) { - _javaVm->DetachCurrentThread(); - } -} - -- (void)updateControlsJson:(NSString *)controlsJson { - if (!_webView) { - return; - } - if (!controlsJson) { - return; - } - _pendingControlsJson = [controlsJson copy]; - [self flushPendingControlsJsonIfReady]; -} - -- (void)flushPendingControlsJsonIfReady { - if (!_webView || !_pendingControlsJson) { - return; - } - if (!_controlsWebReady) { - return; - } - NSString *controlsJson = [_pendingControlsJson copy]; - NSString *jsonString = javaScriptStringLiteral(controlsJson); - NSString *script = [NSString stringWithFormat: - @"(function(){if(!window.playerControls)return 'missing';window.playerControls(JSON.parse(%@));return 'applied';})()", - jsonString]; - [_webView evaluateJavaScript:script completionHandler:^(id _Nullable jsResult, NSError * _Nullable error) { - if (error) { - return; - } - NSString *resultText = [jsResult isKindOfClass:[NSString class]] ? (NSString *)jsResult : @"unknown"; - if ([resultText isEqualToString:@"missing"]) { - _controlsWebReady = NO; - return; - } - if (_pendingControlsJson && [_pendingControlsJson isEqualToString:controlsJson]) { - _pendingControlsJson = nil; - } - }]; -} - -- (void)shutdown { - [[NSNotificationCenter defaultCenter] removeObserver:self]; - [_timer invalidate]; - _timer = nil; - [_resizeSettleTimer invalidate]; - _resizeSettleTimer = nil; - [_fullscreenTransitionTimer invalidate]; - _fullscreenTransitionTimer = nil; - _controlsWebReady = NO; - _pendingControlsJson = nil; - if (_mpvEventQueue) { - dispatch_sync(_mpvEventQueue, ^{}); - } - [_videoView destroyMpvRenderContext]; - if (_mpv) { - mpv_terminate_destroy(_mpv); - _mpv = NULL; - } - [_webView.configuration.userContentController removeScriptMessageHandlerForName:@"player"]; - [_webView removeFromSuperview]; - [_videoView removeFromSuperview]; - _webView = nil; - _videoView = nil; - _scriptHandler = nil; - if (_eventSink) { - BOOL didAttach = NO; - JNIEnv *env = [self jniEnvDidAttach:&didAttach]; - if (env) { - env->DeleteGlobalRef(_eventSink); - } - if (didAttach) { - _javaVm->DetachCurrentThread(); - } - _eventSink = nullptr; - } - _eventMethod = nullptr; - _javaVm = nullptr; -} - -- (void)setPaused:(BOOL)paused { - if (!_mpv) return; - int flag = paused ? 1 : 0; - mpv_set_property(_mpv, "pause", MPV_FORMAT_FLAG, &flag); - _cachedPaused.store(paused); -} - -- (BOOL)isPaused { - return _cachedPaused.load(); -} - -- (BOOL)rawIsPaused { - if (!_mpv) return _cachedPaused.load(); - int flag = 1; - mpv_get_property(_mpv, "pause", MPV_FORMAT_FLAG, &flag); - return flag != 0; -} - -- (void)seekToMilliseconds:(long long)positionMs { - if (!_mpv) return; - std::string seconds = std::to_string((double)positionMs / 1000.0); - const char *command[] = {"seek", seconds.c_str(), "absolute+keyframes", NULL}; - mpv_command(_mpv, command); - _cachedPositionSeconds.store(fmax((double)positionMs / 1000.0, 0.0)); -} - -- (void)seekByMilliseconds:(long long)offsetMs { - if (!_mpv) return; - std::string seconds = std::to_string((double)offsetMs / 1000.0); - const char *command[] = {"seek", seconds.c_str(), "relative+keyframes", NULL}; - mpv_command(_mpv, command); - double nextPosition = fmax(_cachedPositionSeconds.load() + ((double)offsetMs / 1000.0), 0.0); - _cachedPositionSeconds.store(nextPosition); -} - -- (void)setSpeed:(double)speed { - if (!_mpv) return; - double clamped = fmax(0.25, fmin(4.0, speed)); - mpv_set_property(_mpv, "speed", MPV_FORMAT_DOUBLE, &clamped); - _cachedSpeed.store(clamped); -} - -- (double)speed { - return _cachedSpeed.load(); -} - -- (double)rawSpeed { - return [self doubleProperty:"speed" fallback:_cachedSpeed.load()]; -} - -- (void)setResizeMode:(int)mode { - if (!_mpv) return; - NSString *panscan = @"0.0"; - switch (mode) { - case 1: - case 2: - panscan = @"1.0"; - break; - default: - break; - } - - dispatch_queue_t queue = _mpvEventQueue; - if (!queue) { - [self setStringProperty:"panscan" value:panscan]; - [self setStringProperty:"video-unscaled" value:@"no"]; - return; - } - - dispatch_async(queue, ^{ - mpv_handle *mpv = self->_mpv; - if (!mpv) { - return; - } - mpv_set_property_string(mpv, "panscan", panscan.UTF8String); - mpv_set_property_string(mpv, "video-unscaled", "no"); - }); -} - -- (long long)durationMs { - return (long long)llround(fmax(_cachedDurationSeconds.load(), 0.0) * 1000.0); -} - -- (long long)positionMs { - return (long long)llround(fmax(_cachedPositionSeconds.load(), 0.0) * 1000.0); -} - -- (double)rawPositionSeconds { - double position = [self doubleProperty:"time-pos" fallback:0.0]; - return std::isfinite(position) ? fmax(position, 0.0) : 0.0; -} - -- (double)effectiveCachePositionSeconds { - double position = [self rawPositionSeconds]; - if (_initialStartSeconds > 0.0 && position + 5.0 < _initialStartSeconds) { - return _initialStartSeconds; - } - return position; -} - -- (double)cacheAheadSeconds { - return [self cacheAheadSecondsForPosition:[self rawPositionSeconds]]; -} - -- (double)cacheAheadSecondsForPosition:(double)position { - double safePosition = std::isfinite(position) ? fmax(position, 0.0) : 0.0; - double effectivePosition = safePosition; - if (_initialStartSeconds > 0.0 && safePosition + 5.0 < _initialStartSeconds) { - effectivePosition = _initialStartSeconds; - } - double cacheTime = [self doubleProperty:"demuxer-cache-time" fallback:0.0]; - if (std::isfinite(cacheTime) && cacheTime > 0.0) { - if (cacheTime >= effectivePosition - 5.0) { - return fmax(cacheTime - effectivePosition, 0.0); - } - return cacheTime; - } - - double cacheDuration = [self doubleProperty:"demuxer-cache-duration" fallback:0.0]; - if (std::isfinite(cacheDuration) && cacheDuration > 0.0) { - return cacheDuration; - } - - return 0.0; -} - -- (long long)bufferedPositionMs { - double bufferedPosition = _cachedPositionSeconds.load() + _cachedCacheAheadSeconds.load(); - return (long long)llround(fmax(bufferedPosition, 0.0) * 1000.0); -} - -- (BOOL)isLoading { - return _cachedLoading.load(); -} - -- (BOOL)rawLoadingWithPaused:(BOOL)paused ended:(BOOL)eofReached duration:(double)duration { - BOOL idle = [self flagProperty:"core-idle" fallback:YES]; - BOOL seeking = [self flagProperty:"seeking" fallback:NO]; - BOOL bufferingCache = [self flagProperty:"paused-for-cache" fallback:NO]; - BOOL fileReady = duration > 0.0 - || [self int64Property:"track-list/count" fallback:0] > 0; - return !fileReady || (idle && !paused && !eofReached) || seeking || bufferingCache; -} - -- (BOOL)isEnded { - return _cachedEnded.load(); -} - -- (BOOL)rawIsEnded { - return [self flagProperty:"eof-reached" fallback:_cachedEnded.load()]; -} - -- (void)updateCachedDuration:(double)duration - position:(double)position - cacheAhead:(double)cacheAhead - paused:(BOOL)paused - loading:(BOOL)loading - ended:(BOOL)ended - speed:(double)speed { - _cachedDurationSeconds.store(std::isfinite(duration) ? fmax(duration, 0.0) : 0.0); - _cachedPositionSeconds.store(std::isfinite(position) ? fmax(position, 0.0) : 0.0); - _cachedCacheAheadSeconds.store(std::isfinite(cacheAhead) ? fmax(cacheAhead, 0.0) : 0.0); - _cachedSpeed.store(std::isfinite(speed) ? fmax(0.25, fmin(4.0, speed)) : 1.0); - _cachedPaused.store(paused); - _cachedLoading.store(loading); - _cachedEnded.store(ended); -} - -- (NSString *)audioTracksJson { - return [self tracksJsonForType:@"audio"]; -} - -- (NSString *)subtitleTracksJson { - return [self tracksJsonForType:@"sub"]; -} - -- (void)selectAudioTrackId:(int)trackId { - if (!_mpv) return; - int64_t id = trackId; - mpv_set_property(_mpv, "aid", MPV_FORMAT_INT64, &id); -} - -- (void)selectSubtitleTrackId:(int)trackId { - if (!_mpv) return; - if (trackId < 0) { - [self setStringProperty:"sid" value:@"no"]; - return; - } - int64_t id = trackId; - mpv_set_property(_mpv, "sid", MPV_FORMAT_INT64, &id); -} - -- (void)addSubtitleUrl:(NSString *)url { - if (!_mpv || url.length == 0) return; - [self command:@[@"sub-add", url, @"select"]]; -} - -- (void)removeExternalSubtitles { - if (!_mpv) return; - [self removeExternalSubtitleTracks]; - [self setStringProperty:"sid" value:@"no"]; -} - -- (void)removeExternalSubtitlesAndSelect:(int)trackId { - if (!_mpv) return; - [self removeExternalSubtitleTracks]; - if (trackId >= 0) { - [self selectSubtitleTrackId:trackId]; - } else { - [self setStringProperty:"sid" value:@"no"]; - } -} - -- (void)setSubtitleDelayMs:(int)delayMs { - if (!_mpv) return; - int clamped = MAX(-60000, MIN(60000, delayMs)); - double delaySeconds = (double)clamped / 1000.0; - mpv_set_property(_mpv, "sub-delay", MPV_FORMAT_DOUBLE, &delaySeconds); -} - -- (void)applySubtitleStyleWithTextColor:(NSString *)textColor - backgroundColor:(NSString *)backgroundColor - outlineColor:(NSString *)outlineColor - outlineSize:(double)outlineSize - bold:(BOOL)bold - fontSize:(double)fontSize - subPos:(int)subPos { - if (!_mpv) return; - [self setStringProperty:"sub-ass-override" value:@"force"]; - [self setStringProperty:"sub-color" value:textColor ?: @"#FFFFFFFF"]; - [self setStringProperty:"sub-back-color" value:backgroundColor ?: @"#00000000"]; - [self setStringProperty:"sub-outline-color" value:outlineColor ?: @"#FF000000"]; - [self setStringProperty:"sub-border-style" - value:[(backgroundColor ?: @"") hasPrefix:@"#00"] ? @"outline-and-shadow" : @"opaque-box"]; - [self setStringProperty:"sub-bold" value:bold ? @"yes" : @"no"]; - - double outline = MAX(0.0, MIN(8.0, outlineSize)); - mpv_set_property(_mpv, "sub-outline-size", MPV_FORMAT_DOUBLE, &outline); - - double size = MAX(24.0, MIN(96.0, fontSize)); - mpv_set_property(_mpv, "sub-font-size", MPV_FORMAT_DOUBLE, &size); - - int64_t position = MAX(0, MIN(150, subPos)); - mpv_set_property(_mpv, "sub-pos", MPV_FORMAT_INT64, &position); -} - -- (double)doubleProperty:(const char *)name fallback:(double)fallback { - if (!_mpv) return fallback; - double value = fallback; - if (mpv_get_property(_mpv, name, MPV_FORMAT_DOUBLE, &value) < 0) { - return fallback; - } - return value; -} - -- (long long)int64Property:(const char *)name fallback:(long long)fallback { - if (!_mpv) return fallback; - int64_t value = fallback; - if (mpv_get_property(_mpv, name, MPV_FORMAT_INT64, &value) < 0) { - return fallback; - } - return value; -} - -- (BOOL)flagProperty:(const char *)name fallback:(BOOL)fallback { - if (!_mpv) return fallback; - int flag = fallback ? 1 : 0; - if (mpv_get_property(_mpv, name, MPV_FORMAT_FLAG, &flag) < 0) { - return fallback; - } - return flag != 0; -} - -- (NSString *)stringProperty:(const char *)name fallback:(NSString *)fallback { - if (!_mpv) return fallback ?: @""; - char *value = nullptr; - if (mpv_get_property(_mpv, name, MPV_FORMAT_STRING, &value) < 0 || !value) { - return fallback ?: @""; - } - NSString *result = [NSString stringWithUTF8String:value] ?: (fallback ?: @""); - mpv_free(value); - return result; -} - -- (void)setStringProperty:(const char *)name value:(NSString *)value { - if (!_mpv) return; - mpv_set_property_string(_mpv, name, (value ?: @"").UTF8String); -} - -- (void)command:(NSArray *)args { - if (!_mpv || args.count == 0) return; - std::vector cargs; - cargs.reserve(args.count + 1); - for (NSString *arg in args) { - cargs.push_back((arg ?: @"").UTF8String); - } - cargs.push_back(nullptr); - mpv_command(_mpv, cargs.data()); -} - -- (void)removeExternalSubtitleTracks { - long long count = [self int64Property:"track-list/count" fallback:0]; - if (count <= 0) return; - for (long long index = count - 1; index >= 0; index--) { - NSString *typeKey = [NSString stringWithFormat:@"track-list/%lld/type", index]; - NSString *externalKey = [NSString stringWithFormat:@"track-list/%lld/external", index]; - NSString *idKey = [NSString stringWithFormat:@"track-list/%lld/id", index]; - NSString *type = [self stringProperty:typeKey.UTF8String fallback:@""]; - BOOL external = [self flagProperty:externalKey.UTF8String fallback:NO]; - if ([type isEqualToString:@"sub"] && external) { - long long trackId = [self int64Property:idKey.UTF8String fallback:-1]; - if (trackId >= 0) { - [self command:@[@"sub-remove", [NSString stringWithFormat:@"%lld", trackId]]]; - } - } - } -} - -- (NSString *)tracksJsonForType:(NSString *)wantedType { - if (!_mpv) return @"[]"; - NSMutableArray *tracks = [NSMutableArray array]; - long long count = [self int64Property:"track-list/count" fallback:0]; - int logicalIndex = 0; - - for (long long index = 0; index < count; index++) { - NSString *prefix = [NSString stringWithFormat:@"track-list/%lld", index]; - NSString *type = [self stringProperty:[[prefix stringByAppendingString:@"/type"] UTF8String] fallback:@""]; - if (![type isEqualToString:wantedType]) { - continue; - } - - long long trackId = [self int64Property:[[prefix stringByAppendingString:@"/id"] UTF8String] fallback:logicalIndex + 1]; - NSString *title = [self trackStringAtIndex:index field:@"title"]; - NSString *language = [self trackStringAtIndex:index field:@"lang"]; - NSString *codec = [self trackStringAtIndex:index field:@"codec"]; - NSString *decoderDescription = [self trackStringAtIndex:index field:@"decoder-desc"]; - NSString *channels = [self trackStringAtIndex:index field:@"demux-channels"]; - long long channelCount = [self int64Property:[[prefix stringByAppendingString:@"/demux-channel-count"] UTF8String] fallback:0]; - BOOL selected = [self flagProperty:[[prefix stringByAppendingString:@"/selected"] UTF8String] fallback:NO]; - BOOL forced = [self flagProperty:[[prefix stringByAppendingString:@"/forced"] UTF8String] fallback:NO]; - NSString *label = [self formatTrackTitleWithType:type - index:logicalIndex - title:title - language:language - codec:codec - decoderDescription:decoderDescription - channels:channels - channelCount:(int)channelCount]; - [tracks addObject:@{ - @"index": @(logicalIndex), - @"id": [NSString stringWithFormat:@"%lld", trackId], - @"label": label ?: @"", - @"language": language ?: @"", - @"selected": @(selected), - @"forced": @(forced), - }]; - logicalIndex += 1; - } - - NSData *data = [NSJSONSerialization dataWithJSONObject:tracks options:0 error:nil]; - if (!data) return @"[]"; - return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] ?: @"[]"; -} - -- (NSString *)trackStringAtIndex:(long long)index field:(NSString *)field { - NSString *key = [NSString stringWithFormat:@"track-list/%lld/%@", index, field]; - return [[self stringProperty:key.UTF8String fallback:@""] stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; -} - -- (NSString *)formatTrackTitleWithType:(NSString *)type - index:(int)index - title:(NSString *)title - language:(NSString *)language - codec:(NSString *)codec - decoderDescription:(NSString *)decoderDescription - channels:(NSString *)channels - channelCount:(int)channelCount { - NSString *base = [self ifNotBlank:title] - ?: [self localizedLanguageName:language] - ?: ([type isEqualToString:@"sub"] - ? [NSString stringWithFormat:@"Subtitle %d", index + 1] - : [NSString stringWithFormat:@"Track %d", index + 1]); - NSString *codecName = [self codecDisplayName:codec] ?: [self codecDisplayName:decoderDescription]; - NSString *channelName = [type isEqualToString:@"audio"] - ? [self channelLayoutNameWithChannels:channels channelCount:channelCount] - : nil; - NSMutableArray *details = [NSMutableArray array]; - for (NSString *detail in @[channelName ?: @"", codecName ?: @""]) { - if (detail.length == 0) continue; - if ([base rangeOfString:detail options:NSCaseInsensitiveSearch].location == NSNotFound) { - [details addObject:detail]; - } - } - return details.count == 0 - ? base - : [NSString stringWithFormat:@"%@ (%@)", base, [details componentsJoinedByString:@", "]]; -} - -- (NSString *)ifNotBlank:(NSString *)value { - NSString *trimmed = [(value ?: @"") stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; - return trimmed.length == 0 ? nil : trimmed; -} - -- (NSString *)localizedLanguageName:(NSString *)languageCode { - NSString *code = [self ifNotBlank:languageCode]; - if (!code) return nil; - return [[NSLocale currentLocale] displayNameForKey:NSLocaleLanguageCode value:code] ?: code; -} - -- (NSString *)channelLayoutNameWithChannels:(NSString *)channels channelCount:(int)channelCount { - NSString *normalized = [self ifNotBlank:channels]; - if (normalized && ![normalized isEqualToString:@"unknown"]) { - NSString *lower = normalized.lowercaseString; - if ([lower isEqualToString:@"mono"]) return @"Mono"; - if ([lower isEqualToString:@"stereo"]) return @"Stereo"; - return normalized; - } - switch (channelCount) { - case 1: return @"Mono"; - case 2: return @"Stereo"; - case 6: return @"5.1"; - case 8: return @"7.1"; - default: - return channelCount > 0 ? [NSString stringWithFormat:@"%dch", channelCount] : nil; - } -} - -- (NSString *)codecDisplayName:(NSString *)value { - NSString *raw = [self ifNotBlank:value]; - if (!raw) return nil; - NSString *codec = raw.lowercaseString; - if ([codec containsString:@"eac3"] || [codec containsString:@"e-ac-3"] || [codec containsString:@"e ac-3"]) { - return ([codec containsString:@"joc"] || [codec containsString:@"atmos"]) ? @"E-AC-3-JOC" : @"E-AC-3"; - } - if ([codec containsString:@"truehd"] || [codec containsString:@"true hd"]) return @"TrueHD"; - if ([codec containsString:@"ac3"] || [codec containsString:@"ac-3"]) return @"AC-3"; - if ([codec containsString:@"dts-hd"] || [codec containsString:@"dtshd"] || [codec containsString:@"dts hd"]) return @"DTS-HD"; - if ([codec containsString:@"dts"] || [codec isEqualToString:@"dca"]) return @"DTS"; - if ([codec containsString:@"aac"]) return @"AAC"; - if ([codec containsString:@"mp3"] || [codec containsString:@"mpeg audio"]) return @"MP3"; - if ([codec containsString:@"mp2"]) return @"MP2"; - if ([codec containsString:@"opus"]) return @"Opus"; - if ([codec containsString:@"vorbis"]) return @"Vorbis"; - if ([codec containsString:@"flac"]) return @"FLAC"; - if ([codec containsString:@"alac"]) return @"ALAC"; - if ([codec containsString:@"pcm"] || [codec containsString:@"wav"]) return @"WAV"; - if ([codec containsString:@"amr_wb"] || [codec containsString:@"amr-wb"]) return @"AMR-WB"; - if ([codec containsString:@"amr_nb"] || [codec containsString:@"amr-nb"]) return @"AMR-NB"; - if ([codec containsString:@"amr"]) return @"AMR"; - if ([codec containsString:@"iamf"]) return @"IAMF"; - if ([codec containsString:@"mpegh"] || [codec containsString:@"mpeg-h"]) return @"MPEG-H"; - if ([codec containsString:@"pgs"] || [codec containsString:@"hdmv"]) return @"PGS"; - if ([codec containsString:@"subrip"] || [codec isEqualToString:@"srt"]) return @"SRT"; - if ([codec containsString:@"ass"] || [codec containsString:@"ssa"]) return @"SSA"; - if ([codec containsString:@"webvtt"] || [codec isEqualToString:@"vtt"]) return @"VTT"; - if ([codec containsString:@"ttml"]) return @"TTML"; - if ([codec containsString:@"mov_text"] || [codec containsString:@"tx3g"]) return @"TX3G"; - if ([codec containsString:@"dvb"]) return @"DVB"; - return raw; -} - -- (void)handleScriptMessage:(NSDictionary *)message { - NSString *type = message[@"type"]; - if (![type isKindOfClass:[NSString class]]) { - return; - } - - id rawValue = message[@"value"]; - NSNumber *value = [rawValue isKindOfClass:[NSNumber class]] ? rawValue : nil; - if ([type isEqualToString:@"controlsReady"]) { - _controlsWebReady = YES; - [self flushPendingControlsJsonIfReady]; - [self syncControls]; - return; - } - if ([type isEqualToString:@"selectAudioTrack"] && value) { - [self selectAudioTrackId:(int)llround(value.doubleValue)]; - [self syncControls]; - return; - } - if ([type isEqualToString:@"selectSubtitleTrack"] && value) { - [self selectSubtitleTrackId:(int)llround(value.doubleValue)]; - [self syncControls]; - return; - } - if ([type isEqualToString:@"toggleFullscreen"]) { - [self beginFullscreenTransitionWithReason:@"control-toggle"]; - } - - if (_eventSink && _eventMethod) { - [self sendPlayerEvent:type value:value ? value.doubleValue : 0.0]; - return; - } - - if ([type isEqualToString:@"toggle"]) { - [self setPaused:![self isPaused]]; - [self syncControls]; - } else if ([type isEqualToString:@"seekPercent"]) { - double duration = [self doubleProperty:"duration" fallback:0.0]; - if (duration > 0.0 && value) { - [self seekToMilliseconds:(long long)llround(duration * value.doubleValue * 1000.0)]; - } - } else if ([type isEqualToString:@"scrubFinish"] && value) { - [self seekToMilliseconds:(long long)llround(value.doubleValue)]; - } -} - -@end - -static void runOnMainSync(dispatch_block_t block) { - if ([NSThread isMainThread]) { - block(); - } else { - dispatch_sync(dispatch_get_main_queue(), block); - } -} - -static void runOnMainAsync(dispatch_block_t block) { - if ([NSThread isMainThread]) { - block(); - } else { - dispatch_async(dispatch_get_main_queue(), block); - } -} - -static void throwJavaError(JNIEnv *env, NSString *message) { - jclass exceptionClass = env->FindClass("java/lang/IllegalStateException"); - if (exceptionClass) { - env->ThrowNew(exceptionClass, message.UTF8String); - } -} - -static std::string jstringToString(JNIEnv *env, jstring value) { - if (!value) return std::string(); - jsize length = env->GetStringLength(value); - const jchar *chars = env->GetStringChars(value, nullptr); - if (!chars) { - return std::string(); - } - NSString *string = [NSString stringWithCharacters:(const unichar *)chars length:(NSUInteger)length]; - env->ReleaseStringChars(value, chars); - - NSData *data = [string dataUsingEncoding:NSUTF8StringEncoding]; - if (!data) { - return std::string(); - } - std::string result((const char *)data.bytes, data.length); - return result; -} - -static NSArray *jstringArrayToNSArray(JNIEnv *env, jobjectArray values) { - NSMutableArray *result = [NSMutableArray array]; - if (!values) { - return result; - } - jsize count = env->GetArrayLength(values); - for (jsize index = 0; index < count; index++) { - jstring item = (jstring)env->GetObjectArrayElement(values, index); - std::string value = jstringToString(env, item); - if (!value.empty()) { - [result addObject:[NSString stringWithUTF8String:value.c_str()]]; - } - env->DeleteLocalRef(item); - } - return result; -} - -extern "C" JNIEXPORT jlong JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_create( - JNIEnv *env, - jobject /* bridge */, - jlong hostViewPtr, - jstring sourceUrl, - jobjectArray headerLines, - jboolean playWhenReady, - jlong initialPositionMs, - jstring controlsPageUrl, - jobject eventSink -) { - NSView *hostView = (__bridge NSView *)(void *)(intptr_t)hostViewPtr; - if (!hostView) { - throwJavaError(env, @"Unable to resolve the AWT host NSView for native playback."); - return 0; - } - - JavaVM *javaVm = nullptr; - env->GetJavaVM(&javaVm); - jobject eventSinkRef = nullptr; - jmethodID eventMethod = nullptr; - if (eventSink) { - eventSinkRef = env->NewGlobalRef(eventSink); - jclass eventSinkClass = env->GetObjectClass(eventSink); - eventMethod = env->GetMethodID(eventSinkClass, "onPlayerEvent", "(Ljava/lang/String;D)V"); - env->DeleteLocalRef(eventSinkClass); - if (!eventMethod) { - if (eventSinkRef) { - env->DeleteGlobalRef(eventSinkRef); - } - throwJavaError(env, @"Native player event sink is missing onPlayerEvent(String, Double)."); - return 0; - } - } - - std::string source = jstringToString(env, sourceUrl); - std::string controls = jstringToString(env, controlsPageUrl); - NSArray *headers = jstringArrayToNSArray(env, headerLines); - __block MpvWebPlayer *player = nil; - __block NSString *error = nil; - runOnMainSync(^{ - @try { - player = [[MpvWebPlayer alloc] - initWithHostView:hostView - sourceUrl:[NSString stringWithUTF8String:source.c_str()] - headerLines:headers - playWhenReady:playWhenReady == JNI_TRUE - initialPositionMs:initialPositionMs - controlsUrl:[NSString stringWithUTF8String:controls.c_str()] - javaVm:javaVm - eventSink:eventSinkRef - eventMethod:eventMethod]; - } @catch (NSException *exception) { - error = exception.reason ?: exception.name; - } - }); - - if (error) { - if (eventSinkRef) { - env->DeleteGlobalRef(eventSinkRef); - } - throwJavaError(env, error); - return 0; - } - - return (jlong)(intptr_t)CFBridgingRetain(player); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_dispose( - JNIEnv * /* env */, - jobject /* bridge */, - jlong handle -) { - if (handle == 0) return; - MpvWebPlayer *player = (__bridge_transfer MpvWebPlayer *)(void *)(intptr_t)handle; - runOnMainSync(^{ - [player shutdown]; - }); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_updateControls( - JNIEnv *env, - jobject /* bridge */, - jlong handle, - jstring controlsJson -) { - if (handle == 0) return; - std::string controls = jstringToString(env, controlsJson); - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - runOnMainAsync(^{ - [player updateControlsJson:[NSString stringWithUTF8String:controls.c_str()]]; - }); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_setPaused( - JNIEnv * /* env */, - jobject /* bridge */, - jlong handle, - jboolean paused -) { - if (handle == 0) return; - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - runOnMainAsync(^{ - [player setPaused:paused == JNI_TRUE]; - }); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_seekTo( - JNIEnv * /* env */, - jobject /* bridge */, - jlong handle, - jlong positionMs -) { - if (handle == 0) return; - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - runOnMainAsync(^{ - [player seekToMilliseconds:positionMs]; - }); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_seekBy( - JNIEnv * /* env */, - jobject /* bridge */, - jlong handle, - jlong offsetMs -) { - if (handle == 0) return; - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - runOnMainAsync(^{ - [player seekByMilliseconds:offsetMs]; - }); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_setSpeed( - JNIEnv * /* env */, - jobject /* bridge */, - jlong handle, - jfloat speed -) { - if (handle == 0) return; - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - runOnMainAsync(^{ - [player setSpeed:speed]; - }); -} - -extern "C" JNIEXPORT jlong JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_durationMs( - JNIEnv * /* env */, - jobject /* bridge */, - jlong handle -) { - if (handle == 0) return 0; - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - return [player durationMs]; -} - -extern "C" JNIEXPORT jlong JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_positionMs( - JNIEnv * /* env */, - jobject /* bridge */, - jlong handle -) { - if (handle == 0) return 0; - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - return [player positionMs]; -} - -extern "C" JNIEXPORT jlong JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_bufferedPositionMs( - JNIEnv * /* env */, - jobject /* bridge */, - jlong handle -) { - if (handle == 0) return 0; - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - return [player bufferedPositionMs]; -} - -extern "C" JNIEXPORT jboolean JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_isLoading( - JNIEnv * /* env */, - jobject /* bridge */, - jlong handle -) { - if (handle == 0) return JNI_TRUE; - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - return [player isLoading] ? JNI_TRUE : JNI_FALSE; -} - -extern "C" JNIEXPORT jboolean JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_isEnded( - JNIEnv * /* env */, - jobject /* bridge */, - jlong handle -) { - if (handle == 0) return JNI_FALSE; - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - return [player isEnded] ? JNI_TRUE : JNI_FALSE; -} - -extern "C" JNIEXPORT jboolean JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_isPaused( - JNIEnv * /* env */, - jobject /* bridge */, - jlong handle -) { - if (handle == 0) return JNI_TRUE; - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - return [player isPaused] ? JNI_TRUE : JNI_FALSE; -} - -extern "C" JNIEXPORT jfloat JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_speed( - JNIEnv * /* env */, - jobject /* bridge */, - jlong handle -) { - if (handle == 0) return 1.0f; - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - return (jfloat)[player speed]; -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_setResizeMode( - JNIEnv * /* env */, - jobject /* bridge */, - jlong handle, - jint mode -) { - if (handle == 0) return; - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - runOnMainAsync(^{ - [player setResizeMode:(int)mode]; - }); -} - -extern "C" JNIEXPORT jstring JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_audioTracksJson( - JNIEnv *env, - jobject /* bridge */, - jlong handle -) { - if (handle == 0) return env->NewStringUTF("[]"); - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - NSString *json = [player audioTracksJson] ?: @"[]"; - return env->NewStringUTF(json.UTF8String); -} - -extern "C" JNIEXPORT jstring JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_subtitleTracksJson( - JNIEnv *env, - jobject /* bridge */, - jlong handle -) { - if (handle == 0) return env->NewStringUTF("[]"); - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - NSString *json = [player subtitleTracksJson] ?: @"[]"; - return env->NewStringUTF(json.UTF8String); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_selectAudioTrack( - JNIEnv * /* env */, - jobject /* bridge */, - jlong handle, - jint trackId -) { - if (handle == 0) return; - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - runOnMainAsync(^{ - [player selectAudioTrackId:(int)trackId]; - }); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_selectSubtitleTrack( - JNIEnv * /* env */, - jobject /* bridge */, - jlong handle, - jint trackId -) { - if (handle == 0) return; - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - runOnMainAsync(^{ - [player selectSubtitleTrackId:(int)trackId]; - }); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_addSubtitleUrl( - JNIEnv *env, - jobject /* bridge */, - jlong handle, - jstring url -) { - if (handle == 0) return; - std::string subtitleUrl = jstringToString(env, url); - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - runOnMainAsync(^{ - [player addSubtitleUrl:[NSString stringWithUTF8String:subtitleUrl.c_str()]]; - }); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_clearExternalSubtitles( - JNIEnv * /* env */, - jobject /* bridge */, - jlong handle -) { - if (handle == 0) return; - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - runOnMainAsync(^{ - [player removeExternalSubtitles]; - }); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_clearExternalSubtitlesAndSelect( - JNIEnv * /* env */, - jobject /* bridge */, - jlong handle, - jint trackId -) { - if (handle == 0) return; - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - runOnMainAsync(^{ - [player removeExternalSubtitlesAndSelect:(int)trackId]; - }); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_setSubtitleDelayMs( - JNIEnv * /* env */, - jobject /* bridge */, - jlong handle, - jint delayMs -) { - if (handle == 0) return; - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - runOnMainAsync(^{ - [player setSubtitleDelayMs:(int)delayMs]; - }); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_applySubtitleStyle( - JNIEnv *env, - jobject /* bridge */, - jlong handle, - jstring textColor, - jstring backgroundColor, - jstring outlineColor, - jfloat outlineSize, - jboolean bold, - jfloat fontSize, - jint subPos -) { - if (handle == 0) return; - std::string text = jstringToString(env, textColor); - std::string background = jstringToString(env, backgroundColor); - std::string outline = jstringToString(env, outlineColor); - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - runOnMainAsync(^{ - [player applySubtitleStyleWithTextColor:[NSString stringWithUTF8String:text.c_str()] - backgroundColor:[NSString stringWithUTF8String:background.c_str()] - outlineColor:[NSString stringWithUTF8String:outline.c_str()] - outlineSize:(double)outlineSize - bold:bold == JNI_TRUE - fontSize:(double)fontSize - subPos:(int)subPos]; - }); -} diff --git a/composeApp/src/desktopMain/native/windows/player_bridge.cpp b/composeApp/src/desktopMain/native/windows/player_bridge.cpp deleted file mode 100644 index 249009a5..00000000 --- a/composeApp/src/desktopMain/native/windows/player_bridge.cpp +++ /dev/null @@ -1,1965 +0,0 @@ -#ifndef WIN32_LEAN_AND_MEAN -#define WIN32_LEAN_AND_MEAN -#endif -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -using Microsoft::WRL::Callback; -using Microsoft::WRL::ComPtr; - -extern "C" { -typedef struct mpv_handle mpv_handle; - -typedef enum mpv_format { - MPV_FORMAT_NONE = 0, - MPV_FORMAT_STRING = 1, - MPV_FORMAT_OSD_STRING = 2, - MPV_FORMAT_FLAG = 3, - MPV_FORMAT_INT64 = 4, - MPV_FORMAT_DOUBLE = 5, -} mpv_format; - -typedef enum mpv_event_id { - MPV_EVENT_NONE = 0, - MPV_EVENT_SHUTDOWN = 1, -} mpv_event_id; - -typedef struct mpv_event { - mpv_event_id event_id; - int error; - uint64_t reply_userdata; - void *data; -} mpv_event; -} - -namespace { - -HMODULE gModule = nullptr; -constexpr UINT WM_NUVIO_TASK = WM_APP + 0x4E50; -constexpr UINT_PTR NUVIO_TIMER_ID = 0x4E50; -const wchar_t *kMessageWindowClass = L"NuvioPlayerBridgeMessageWindow"; -const wchar_t *kContainerWindowClass = L"NuvioPlayerBridgeContainerWindow"; -constexpr DWORD kDwmwaUseImmersiveDarkMode = 20; -constexpr DWORD kDwmwaUseImmersiveDarkModeLegacy = 19; -constexpr DWORD kDwmwaBorderColor = 34; -constexpr DWORD kDwmwaCaptionColor = 35; -constexpr DWORD kDwmwaTextColor = 36; - -std::wstring toWide(const std::string &value) { - if (value.empty()) return std::wstring(); - int size = MultiByteToWideChar(CP_UTF8, 0, value.data(), (int)value.size(), nullptr, 0); - if (size <= 0) return std::wstring(); - std::wstring result((size_t)size, L'\0'); - MultiByteToWideChar(CP_UTF8, 0, value.data(), (int)value.size(), result.data(), size); - return result; -} - -std::string toUtf8(const std::wstring &value) { - if (value.empty()) return std::string(); - int size = WideCharToMultiByte(CP_UTF8, 0, value.data(), (int)value.size(), nullptr, 0, nullptr, nullptr); - if (size <= 0) return std::string(); - std::string result((size_t)size, '\0'); - WideCharToMultiByte(CP_UTF8, 0, value.data(), (int)value.size(), result.data(), size, nullptr, nullptr); - return result; -} - -std::string jstringToUtf8(JNIEnv *env, jstring value) { - if (!value) return std::string(); - jsize length = env->GetStringLength(value); - const jchar *chars = env->GetStringChars(value, nullptr); - if (!chars) return std::string(); - std::wstring wide(reinterpret_cast(chars), (size_t)length); - env->ReleaseStringChars(value, chars); - return toUtf8(wide); -} - -jstring newJavaStringUtf8(JNIEnv *env, const std::string &value) { - std::wstring wide = toWide(value); - return env->NewString(reinterpret_cast(wide.data()), (jsize)wide.size()); -} - -std::vector jstringArrayToVector(JNIEnv *env, jobjectArray values) { - std::vector result; - if (!values) return result; - jsize count = env->GetArrayLength(values); - result.reserve((size_t)count); - for (jsize index = 0; index < count; index++) { - jstring item = (jstring)env->GetObjectArrayElement(values, index); - std::string value = jstringToUtf8(env, item); - if (!value.empty()) { - result.push_back(value); - } - env->DeleteLocalRef(item); - } - return result; -} - -void throwJavaError(JNIEnv *env, const std::string &message) { - jclass exceptionClass = env->FindClass("java/lang/IllegalStateException"); - if (exceptionClass) { - env->ThrowNew(exceptionClass, message.c_str()); - } -} - -std::string trim(const std::string &value) { - const char *spaces = " \t\r\n"; - size_t start = value.find_first_not_of(spaces); - if (start == std::string::npos) return std::string(); - size_t end = value.find_last_not_of(spaces); - return value.substr(start, end - start + 1); -} - -std::string lowerCopy(std::string value) { - std::transform(value.begin(), value.end(), value.begin(), [](unsigned char ch) { - return (char)std::tolower(ch); - }); - return value; -} - -COLORREF rgbIntToColorRef(jint rgb) { - BYTE red = (BYTE)((rgb >> 16) & 0xFF); - BYTE green = (BYTE)((rgb >> 8) & 0xFF); - BYTE blue = (BYTE)(rgb & 0xFF); - return RGB(red, green, blue); -} - -void setDwmWindowAttribute(HWND hwnd, DWORD attribute, const void *value, DWORD valueSize) { - (void)DwmSetWindowAttribute(hwnd, attribute, value, valueSize); -} - -void applyDwmWindowChrome(HWND hwnd, bool darkMode, COLORREF captionColor, COLORREF borderColor, COLORREF textColor) { - if (!hwnd || !IsWindow(hwnd)) return; - - BOOL enabled = darkMode ? TRUE : FALSE; - HRESULT darkModeResult = DwmSetWindowAttribute( - hwnd, - kDwmwaUseImmersiveDarkMode, - &enabled, - sizeof(enabled) - ); - if (FAILED(darkModeResult)) { - setDwmWindowAttribute(hwnd, kDwmwaUseImmersiveDarkModeLegacy, &enabled, sizeof(enabled)); - } - - setDwmWindowAttribute(hwnd, kDwmwaCaptionColor, &captionColor, sizeof(captionColor)); - setDwmWindowAttribute(hwnd, kDwmwaBorderColor, &borderColor, sizeof(borderColor)); - setDwmWindowAttribute(hwnd, kDwmwaTextColor, &textColor, sizeof(textColor)); -} - -bool containsCaseInsensitive(const std::string &haystack, const std::string &needle) { - return lowerCopy(haystack).find(lowerCopy(needle)) != std::string::npos; -} - -std::string jsonEscape(const std::string &value) { - std::string result; - result.reserve(value.size() + 8); - for (unsigned char ch : value) { - switch (ch) { - case '\\': result += "\\\\"; break; - case '"': result += "\\\""; break; - case '\b': result += "\\b"; break; - case '\f': result += "\\f"; break; - case '\n': result += "\\n"; break; - case '\r': result += "\\r"; break; - case '\t': result += "\\t"; break; - default: - if (ch < 0x20) { - char buffer[8]; - std::snprintf(buffer, sizeof(buffer), "\\u%04x", ch); - result += buffer; - } else { - result.push_back((char)ch); - } - } - } - return result; -} - -std::wstring javaScriptStringLiteral(const std::string &value) { - std::string escaped; - escaped.reserve(value.size() + 8); - escaped.push_back('"'); - escaped += jsonEscape(value); - escaped.push_back('"'); - return toWide(escaped); -} - -std::wstring moduleDirectory() { - wchar_t buffer[MAX_PATH] = {}; - DWORD length = GetModuleFileNameW(gModule, buffer, MAX_PATH); - if (length == 0 || length >= MAX_PATH) return std::wstring(); - std::wstring path(buffer, buffer + length); - size_t separator = path.find_last_of(L"\\/"); - if (separator == std::wstring::npos) return std::wstring(); - return path.substr(0, separator); -} - -std::wstring tempUserDataDirectory() { - wchar_t tempPath[MAX_PATH] = {}; - DWORD length = GetTempPathW(MAX_PATH, tempPath); - std::wstring result = length > 0 ? std::wstring(tempPath, tempPath + length) : L".\\"; - if (!result.empty() && result.back() != L'\\' && result.back() != L'/') { - result.push_back(L'\\'); - } - result += L"NuvioWebView2"; - CreateDirectoryW(result.c_str(), nullptr); - return result; -} - -std::string hresultMessage(const char *operation, HRESULT hr) { - std::ostringstream builder; - builder << operation << " failed: 0x" << std::hex << (unsigned long)hr; - return builder.str(); -} - -struct MpvApi { - using mpv_create_fn = mpv_handle *(*)(); - using mpv_initialize_fn = int (*)(mpv_handle *); - using mpv_terminate_destroy_fn = void (*)(mpv_handle *); - using mpv_set_option_fn = int (*)(mpv_handle *, const char *, mpv_format, void *); - using mpv_set_option_string_fn = int (*)(mpv_handle *, const char *, const char *); - using mpv_set_property_fn = int (*)(mpv_handle *, const char *, mpv_format, void *); - using mpv_set_property_string_fn = int (*)(mpv_handle *, const char *, const char *); - using mpv_get_property_fn = int (*)(mpv_handle *, const char *, mpv_format, void *); - using mpv_command_fn = int (*)(mpv_handle *, const char **); - using mpv_error_string_fn = const char *(*)(int); - using mpv_free_fn = void (*)(void *); - using mpv_wait_event_fn = mpv_event *(*)(mpv_handle *, double); - using mpv_wakeup_fn = void (*)(mpv_handle *); - - HMODULE library = nullptr; - std::once_flag loadOnce; - std::string loadFailure; - - mpv_create_fn create = nullptr; - mpv_initialize_fn initialize = nullptr; - mpv_terminate_destroy_fn terminateDestroy = nullptr; - mpv_set_option_fn setOption = nullptr; - mpv_set_option_string_fn setOptionString = nullptr; - mpv_set_property_fn setProperty = nullptr; - mpv_set_property_string_fn setPropertyString = nullptr; - mpv_get_property_fn getProperty = nullptr; - mpv_command_fn command = nullptr; - mpv_error_string_fn errorString = nullptr; - mpv_free_fn freeValue = nullptr; - mpv_wait_event_fn waitEvent = nullptr; - mpv_wakeup_fn wakeup = nullptr; - - void ensureLoaded() { - std::call_once(loadOnce, [this]() { load(); }); - if (!library) { - throw std::runtime_error(loadFailure.empty() ? "Unable to load libmpv-2.dll." : loadFailure); - } - } - - std::string errorText(int error) { - if (!errorString) return "unknown"; - const char *text = errorString(error); - return text ? text : "unknown"; - } - - void load() { - std::vector candidates; - - wchar_t envPath[32768] = {}; - DWORD envCapacity = (DWORD)(sizeof(envPath) / sizeof(envPath[0])); - DWORD envLength = GetEnvironmentVariableW(L"NUVIO_LIBMPV_PATH", envPath, envCapacity); - if (envLength > 0 && envLength < envCapacity) { - candidates.emplace_back(envPath, envPath + envLength); - } - - std::wstring moduleDir = moduleDirectory(); - if (!moduleDir.empty()) { - candidates.push_back(moduleDir + L"\\libmpv-2.dll"); - } - candidates.push_back(L"libmpv-2.dll"); - candidates.push_back(L"C:\\Program Files (x86)\\Nuvio\\app\\native\\libmpv-2.dll"); - candidates.push_back(L"C:\\msys64\\ucrt64\\bin\\libmpv-2.dll"); - - for (const std::wstring &candidate : candidates) { - if (candidate.find(L'\\') != std::wstring::npos || candidate.find(L'/') != std::wstring::npos) { - library = LoadLibraryExW(candidate.c_str(), nullptr, LOAD_WITH_ALTERED_SEARCH_PATH); - } else { - library = LoadLibraryW(candidate.c_str()); - } - if (library) break; - } - - if (!library) { - loadFailure = "Unable to load libmpv-2.dll. Bundle it under native/windows or set NUVIO_LIBMPV_PATH."; - return; - } - - create = loadSymbol("mpv_create"); - initialize = loadSymbol("mpv_initialize"); - terminateDestroy = loadSymbol("mpv_terminate_destroy"); - setOption = loadSymbol("mpv_set_option"); - setOptionString = loadSymbol("mpv_set_option_string"); - setProperty = loadSymbol("mpv_set_property"); - setPropertyString = loadSymbol("mpv_set_property_string"); - getProperty = loadSymbol("mpv_get_property"); - command = loadSymbol("mpv_command"); - errorString = loadSymbol("mpv_error_string"); - freeValue = loadSymbol("mpv_free"); - waitEvent = loadSymbol("mpv_wait_event"); - wakeup = loadSymbol("mpv_wakeup"); - } - - template - T loadSymbol(const char *name) { - FARPROC symbol = GetProcAddress(library, name); - if (!symbol) { - loadFailure = std::string("libmpv-2.dll is missing export ") + name + "."; - FreeLibrary(library); - library = nullptr; - throw std::runtime_error(loadFailure); - } - return reinterpret_cast(symbol); - } -}; - -MpvApi &mpvApi() { - static MpvApi api; - api.ensureLoaded(); - return api; -} - -class WindowsMpvWebPlayer; -LRESULT CALLBACK messageWindowProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam); -LRESULT CALLBACK containerWindowProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam); - -void registerWindowClasses() { - static std::once_flag once; - std::call_once(once, []() { - WNDCLASSEXW messageClass = {}; - messageClass.cbSize = sizeof(messageClass); - messageClass.lpfnWndProc = messageWindowProc; - messageClass.hInstance = gModule; - messageClass.lpszClassName = kMessageWindowClass; - RegisterClassExW(&messageClass); - - WNDCLASSEXW containerClass = {}; - containerClass.cbSize = sizeof(containerClass); - containerClass.lpfnWndProc = containerWindowProc; - containerClass.hInstance = gModule; - containerClass.hbrBackground = (HBRUSH)GetStockObject(BLACK_BRUSH); - containerClass.lpszClassName = kContainerWindowClass; - RegisterClassExW(&containerClass); - }); -} - -std::mutex gWebView2WarmupMutex; -std::condition_variable gWebView2WarmupCv; -std::thread gWebView2WarmupThread; -DWORD gWebView2WarmupThreadId = 0; -bool gWebView2WarmupStarted = false; -bool gWebView2WarmupReady = false; -bool gWebView2WarmupSucceeded = false; - -void notifyWebView2WarmupReady(bool succeeded) { - { - std::lock_guard lock(gWebView2WarmupMutex); - if (!gWebView2WarmupReady) { - gWebView2WarmupReady = true; - gWebView2WarmupSucceeded = succeeded; - } - } - gWebView2WarmupCv.notify_all(); -} - -void runWebView2WarmupThread(std::string controlsUrl) { - { - std::lock_guard lock(gWebView2WarmupMutex); - gWebView2WarmupThreadId = GetCurrentThreadId(); - } - gWebView2WarmupCv.notify_all(); - - MSG queueProbe = {}; - PeekMessageW(&queueProbe, nullptr, WM_USER, WM_USER, PM_NOREMOVE); - - bool didOleInitialize = false; - ComPtr environment; - ComPtr controller; - ComPtr webView; - EventRegistrationToken messageToken = {}; - EventRegistrationToken navigationToken = {}; - - HRESULT oleResult = OleInitialize(nullptr); - didOleInitialize = SUCCEEDED(oleResult); - if (FAILED(oleResult)) { - notifyWebView2WarmupReady(false); - return; - } - - std::wstring userDataDir = tempUserDataDirectory(); - HRESULT envCallResult = CreateCoreWebView2EnvironmentWithOptions( - nullptr, - userDataDir.c_str(), - nullptr, - Callback( - [&](HRESULT envResult, ICoreWebView2Environment *createdEnvironment) -> HRESULT { - if (FAILED(envResult) || !createdEnvironment) { - notifyWebView2WarmupReady(false); - PostQuitMessage(0); - return S_OK; - } - - environment = createdEnvironment; - HRESULT controllerCallResult = createdEnvironment->CreateCoreWebView2Controller( - HWND_MESSAGE, - Callback( - [&](HRESULT controllerResult, ICoreWebView2Controller *createdController) -> HRESULT { - if (FAILED(controllerResult) || !createdController) { - notifyWebView2WarmupReady(false); - PostQuitMessage(0); - return S_OK; - } - - controller = createdController; - controller->put_IsVisible(FALSE); - createdController->get_CoreWebView2(&webView); - if (!webView) { - notifyWebView2WarmupReady(false); - PostQuitMessage(0); - return S_OK; - } - - webView->add_WebMessageReceived( - Callback( - [&](ICoreWebView2 *, ICoreWebView2WebMessageReceivedEventArgs *args) -> HRESULT { - if (!args) return S_OK; - PWSTR messageJson = nullptr; - if (SUCCEEDED(args->get_WebMessageAsJson(&messageJson)) && messageJson) { - std::wstring message(messageJson); - CoTaskMemFree(messageJson); - if (message.find(L"controlsReady") != std::wstring::npos) { - notifyWebView2WarmupReady(true); - } - } - return S_OK; - } - ).Get(), - &messageToken - ); - - webView->add_NavigationCompleted( - Callback( - [&](ICoreWebView2 *, ICoreWebView2NavigationCompletedEventArgs *args) -> HRESULT { - BOOL navigationSucceeded = FALSE; - if (args) { - args->get_IsSuccess(&navigationSucceeded); - } - notifyWebView2WarmupReady(navigationSucceeded == TRUE); - return S_OK; - } - ).Get(), - &navigationToken - ); - - std::wstring url = toWide(controlsUrl); - HRESULT navigateResult = webView->Navigate(url.c_str()); - if (FAILED(navigateResult)) { - notifyWebView2WarmupReady(false); - PostQuitMessage(0); - } - return S_OK; - } - ).Get() - ); - if (FAILED(controllerCallResult)) { - notifyWebView2WarmupReady(false); - PostQuitMessage(0); - } - return S_OK; - } - ).Get() - ); - if (FAILED(envCallResult)) { - notifyWebView2WarmupReady(false); - } else { - MSG msg = {}; - while (GetMessageW(&msg, nullptr, 0, 0) > 0) { - TranslateMessage(&msg); - DispatchMessageW(&msg); - } - } - - if (webView && messageToken.value != 0) { - webView->remove_WebMessageReceived(messageToken); - } - if (webView && navigationToken.value != 0) { - webView->remove_NavigationCompleted(navigationToken); - } - if (controller) { - controller->Close(); - controller.Reset(); - } - webView.Reset(); - environment.Reset(); - if (didOleInitialize) { - OleUninitialize(); - } -} - -bool startWebView2Warmup(const std::string &controlsUrl) { - { - std::lock_guard lock(gWebView2WarmupMutex); - if (!gWebView2WarmupStarted) { - gWebView2WarmupStarted = true; - gWebView2WarmupReady = false; - gWebView2WarmupSucceeded = false; - gWebView2WarmupThread = std::thread(runWebView2WarmupThread, controlsUrl); - } - } - - std::unique_lock waitLock(gWebView2WarmupMutex); - bool completed = gWebView2WarmupCv.wait_for( - waitLock, - std::chrono::seconds(5), - []() { return gWebView2WarmupReady; } - ); - if (!completed) return false; - return gWebView2WarmupSucceeded; -} - -void stopWebView2Warmup() { - std::thread threadToJoin; - DWORD threadId = 0; - { - std::unique_lock lock(gWebView2WarmupMutex); - if (!gWebView2WarmupStarted) return; - gWebView2WarmupCv.wait_for( - lock, - std::chrono::seconds(1), - []() { return gWebView2WarmupThreadId != 0; } - ); - threadId = gWebView2WarmupThreadId; - } - - if (threadId != 0) { - PostThreadMessageW(threadId, WM_QUIT, 0, 0); - } - - { - std::lock_guard lock(gWebView2WarmupMutex); - if (gWebView2WarmupThread.joinable()) { - threadToJoin = std::move(gWebView2WarmupThread); - } - } - if (threadToJoin.joinable()) { - threadToJoin.join(); - } - - { - std::lock_guard lock(gWebView2WarmupMutex); - gWebView2WarmupStarted = false; - gWebView2WarmupReady = false; - gWebView2WarmupSucceeded = false; - gWebView2WarmupThreadId = 0; - } -} - -class WindowsMpvWebPlayer : public std::enable_shared_from_this { - struct InitializationState { - std::mutex mutex; - std::condition_variable cv; - bool complete = false; - std::string failure; - }; - -public: - void initialize( - HWND host, - const std::string &sourceUrl, - const std::vector &headerLines, - bool playWhenReady, - long long initialPositionMs, - const std::string &controlsUrl, - JavaVM *vm, - jobject sink, - jmethodID method - ) { - if (!host || !IsWindow(host)) { - throw std::runtime_error("Unable to resolve the AWT host HWND for native playback."); - } - - javaVm = vm; - eventSink = sink; - eventMethod = method; - hostHwnd = host; - - auto initState = std::make_shared(); - auto self = shared_from_this(); - uiThread = std::thread( - [self, sourceUrl, headerLines, playWhenReady, initialPositionMs, controlsUrl, initState]() { - self->runNativeUiThread(sourceUrl, headerLines, playWhenReady, initialPositionMs, controlsUrl, initState); - } - ); - - std::unique_lock lock(initState->mutex); - initState->cv.wait(lock, [&]() { return initState->complete; }); - if (!initState->failure.empty()) { - lock.unlock(); - if (uiThread.joinable()) { - uiThread.join(); - } - throw std::runtime_error(initState->failure); - } - } - - void shutdown() { - if (shuttingDown.exchange(true)) { - return; - } - - sendUiTask([self = shared_from_this()]() { - self->cleanupUiResources(); - PostQuitMessage(0); - }); - - stopping.store(true); - { - std::lock_guard lock(mpvMutex); - if (mpv && mpvApi().wakeup) { - mpvApi().wakeup(mpv); - } - } - if (eventThread.joinable()) { - eventThread.join(); - } - { - std::lock_guard lock(mpvMutex); - if (mpv) { - mpvApi().terminateDestroy(mpv); - mpv = nullptr; - } - } - if (uiThread.joinable() && GetCurrentThreadId() != uiThreadId) { - uiThread.join(); - } - - if (eventSink) { - bool didAttach = false; - JNIEnv *env = jniEnvDidAttach(&didAttach); - if (env) { - env->DeleteGlobalRef(eventSink); - } - if (didAttach) { - javaVm->DetachCurrentThread(); - } - eventSink = nullptr; - } - eventMethod = nullptr; - javaVm = nullptr; - } - - void processUiTasks() { - std::deque> tasks; - { - std::lock_guard lock(uiTaskMutex); - tasks.swap(uiTasks); - } - for (auto &task : tasks) { - task(); - } - } - - void onTimer() { - if (shuttingDown.load()) return; - layoutNativeSubviews(); - syncControls(); - } - - void updateControlsJson(const std::string &controlsJson) { - if (controlsJson.empty()) return; - { - std::lock_guard lock(controlsMutex); - pendingControlsJson = controlsJson; - } - postUiTask([self = shared_from_this()]() { - self->flushPendingControlsJsonIfReady(); - }); - } - - void setPaused(bool paused) { - std::lock_guard lock(mpvMutex); - if (!mpv) return; - int flag = paused ? 1 : 0; - mpvApi().setProperty(mpv, "pause", MPV_FORMAT_FLAG, &flag); - } - - bool isPaused() { - return flagProperty("pause", true); - } - - void seekToMilliseconds(long long positionMs) { - std::lock_guard lock(mpvMutex); - if (!mpv) return; - std::string seconds = std::to_string((double)positionMs / 1000.0); - const char *command[] = {"seek", seconds.c_str(), "absolute+keyframes", nullptr}; - mpvApi().command(mpv, command); - } - - void seekByMilliseconds(long long offsetMs) { - std::lock_guard lock(mpvMutex); - if (!mpv) return; - std::string seconds = std::to_string((double)offsetMs / 1000.0); - const char *command[] = {"seek", seconds.c_str(), "relative+keyframes", nullptr}; - mpvApi().command(mpv, command); - } - - void setSpeed(double speed) { - std::lock_guard lock(mpvMutex); - if (!mpv) return; - double clamped = std::max(0.25, std::min(4.0, speed)); - mpvApi().setProperty(mpv, "speed", MPV_FORMAT_DOUBLE, &clamped); - } - - double speed() { - return doubleProperty("speed", 1.0); - } - - void setResizeMode(int mode) { - switch (mode) { - case 1: - case 2: - setStringProperty("panscan", "1.0"); - setStringProperty("video-unscaled", "no"); - break; - default: - setStringProperty("panscan", "0.0"); - setStringProperty("video-unscaled", "no"); - break; - } - } - - long long durationMs() { - return (long long)std::llround(doubleProperty("duration", 0.0) * 1000.0); - } - - long long positionMs() { - return (long long)std::llround(doubleProperty("time-pos", 0.0) * 1000.0); - } - - long long bufferedPositionMs() { - double buffered = rawPositionSeconds() + cacheAheadSeconds(); - return (long long)std::llround(std::max(buffered, 0.0) * 1000.0); - } - - bool isLoading() { - bool paused = isPaused(); - bool eofReached = isEnded(); - bool idle = flagProperty("core-idle", true); - bool seeking = flagProperty("seeking", false); - bool bufferingCache = flagProperty("paused-for-cache", false); - bool fileReady = doubleProperty("duration", 0.0) > 0.0 || int64Property("track-list/count", 0) > 0; - return !fileReady || (idle && !paused && !eofReached) || seeking || bufferingCache; - } - - bool isEnded() { - return flagProperty("eof-reached", false); - } - - std::string audioTracksJson() { - return tracksJsonForType("audio"); - } - - std::string subtitleTracksJson() { - return tracksJsonForType("sub"); - } - - void selectAudioTrackId(int trackId) { - std::lock_guard lock(mpvMutex); - if (!mpv) return; - int64_t id = trackId; - mpvApi().setProperty(mpv, "aid", MPV_FORMAT_INT64, &id); - } - - void selectSubtitleTrackId(int trackId) { - std::lock_guard lock(mpvMutex); - if (!mpv) return; - if (trackId < 0) { - mpvApi().setPropertyString(mpv, "sid", "no"); - return; - } - int64_t id = trackId; - mpvApi().setProperty(mpv, "sid", MPV_FORMAT_INT64, &id); - } - - void addSubtitleUrl(const std::string &url) { - if (url.empty()) return; - command({"sub-add", url, "select"}); - } - - void removeExternalSubtitles() { - removeExternalSubtitleTracks(); - setStringProperty("sid", "no"); - } - - void removeExternalSubtitlesAndSelect(int trackId) { - removeExternalSubtitleTracks(); - if (trackId >= 0) { - selectSubtitleTrackId(trackId); - } else { - setStringProperty("sid", "no"); - } - } - - void setSubtitleDelayMs(int delayMs) { - std::lock_guard lock(mpvMutex); - if (!mpv) return; - int clamped = std::max(-60000, std::min(60000, delayMs)); - double delaySeconds = (double)clamped / 1000.0; - mpvApi().setProperty(mpv, "sub-delay", MPV_FORMAT_DOUBLE, &delaySeconds); - } - - void applySubtitleStyle( - const std::string &textColor, - const std::string &backgroundColor, - const std::string &outlineColor, - double outlineSize, - bool bold, - double fontSize, - int subPos - ) { - setStringProperty("sub-ass-override", "force"); - setStringProperty("sub-color", textColor.empty() ? "#FFFFFFFF" : textColor); - setStringProperty("sub-back-color", backgroundColor.empty() ? "#00000000" : backgroundColor); - setStringProperty("sub-outline-color", outlineColor.empty() ? "#FF000000" : outlineColor); - setStringProperty( - "sub-border-style", - backgroundColor.rfind("#00", 0) == 0 ? "outline-and-shadow" : "opaque-box" - ); - setStringProperty("sub-bold", bold ? "yes" : "no"); - - { - std::lock_guard lock(mpvMutex); - if (!mpv) return; - double outline = std::max(0.0, std::min(8.0, outlineSize)); - double size = std::max(24.0, std::min(96.0, fontSize)); - int64_t position = std::max(0, std::min(150, subPos)); - mpvApi().setProperty(mpv, "sub-outline-size", MPV_FORMAT_DOUBLE, &outline); - mpvApi().setProperty(mpv, "sub-font-size", MPV_FORMAT_DOUBLE, &size); - mpvApi().setProperty(mpv, "sub-pos", MPV_FORMAT_INT64, &position); - } - } - -private: - HWND hostHwnd = nullptr; - HWND containerHwnd = nullptr; - HWND messageHwnd = nullptr; - DWORD uiThreadId = 0; - bool didOleInitialize = false; - std::thread uiThread; - - ComPtr environment; - ComPtr controller; - ComPtr webView; - EventRegistrationToken messageToken = {}; - - std::mutex uiTaskMutex; - std::deque> uiTasks; - - std::mutex mpvMutex; - mpv_handle *mpv = nullptr; - std::thread eventThread; - std::atomic_bool stopping = false; - std::atomic_bool shuttingDown = false; - - JavaVM *javaVm = nullptr; - jobject eventSink = nullptr; - jmethodID eventMethod = nullptr; - - std::atomic_bool controlsWebReady = false; - std::mutex controlsMutex; - std::string pendingControlsJson; - double initialStartSeconds = 0.0; - - friend LRESULT CALLBACK messageWindowProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam); - friend LRESULT CALLBACK containerWindowProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam); - - void runNativeUiThread( - std::string sourceUrl, - std::vector headerLines, - bool playWhenReady, - long long initialPositionMs, - std::string controlsUrl, - std::shared_ptr initState - ) { - std::string failure; - try { - initializeOnNativeUiThread(sourceUrl, headerLines, playWhenReady, initialPositionMs, controlsUrl); - } catch (const std::exception &error) { - failure = error.what(); - cleanupUiResources(); - } - - { - std::lock_guard lock(initState->mutex); - initState->failure = failure; - initState->complete = true; - } - initState->cv.notify_one(); - - if (!failure.empty()) { - return; - } - - MSG msg = {}; - while (GetMessageW(&msg, nullptr, 0, 0) > 0) { - TranslateMessage(&msg); - DispatchMessageW(&msg); - } - } - - void initializeOnNativeUiThread( - const std::string &sourceUrl, - const std::vector &headerLines, - bool playWhenReady, - long long initialPositionMs, - const std::string &controlsUrl - ) { - registerWindowClasses(); - uiThreadId = GetCurrentThreadId(); - HRESULT oleResult = OleInitialize(nullptr); - didOleInitialize = SUCCEEDED(oleResult); - if (FAILED(oleResult)) { - throw std::runtime_error(hresultMessage("OleInitialize", oleResult)); - } - - messageHwnd = CreateWindowExW( - 0, - kMessageWindowClass, - L"", - 0, - 0, - 0, - 0, - 0, - HWND_MESSAGE, - nullptr, - gModule, - this - ); - if (!messageHwnd) { - throw std::runtime_error("Unable to create Windows player message window."); - } - - RECT bounds = {}; - GetClientRect(hostHwnd, &bounds); - LONG width = std::max(1, bounds.right - bounds.left); - LONG height = std::max(1, bounds.bottom - bounds.top); - containerHwnd = CreateWindowExW( - 0, - kContainerWindowClass, - L"", - WS_CHILD | WS_VISIBLE | WS_CLIPSIBLINGS, - 0, - 0, - width, - height, - hostHwnd, - nullptr, - gModule, - this - ); - if (!containerHwnd) { - throw std::runtime_error("Unable to create native player container window."); - } - - startWebView(controlsUrl); - startMpv(sourceUrl, headerLines, playWhenReady, initialPositionMs); - layoutNativeSubviews(); - if (!SetTimer(messageHwnd, NUVIO_TIMER_ID, 500, nullptr)) { - throw std::runtime_error("Unable to start native player timer."); - } - } - - void cleanupUiResources() { - if (messageHwnd) { - KillTimer(messageHwnd, NUVIO_TIMER_ID); - } - if (webView && messageToken.value != 0) { - webView->remove_WebMessageReceived(messageToken); - messageToken.value = 0; - } - if (controller) { - controller->Close(); - controller.Reset(); - } - webView.Reset(); - environment.Reset(); - if (containerHwnd) { - DestroyWindow(containerHwnd); - containerHwnd = nullptr; - } - if (messageHwnd) { - HWND hwnd = messageHwnd; - messageHwnd = nullptr; - DestroyWindow(hwnd); - } - if (didOleInitialize) { - OleUninitialize(); - didOleInitialize = false; - } - } - - void postUiTask(std::function task) { - if (shuttingDown.load()) return; - { - std::lock_guard lock(uiTaskMutex); - uiTasks.push_back(std::move(task)); - } - HWND target = messageHwnd; - if (target) { - PostMessageW(target, WM_NUVIO_TASK, 0, 0); - } - } - - void sendUiTask(std::function task) { - if (GetCurrentThreadId() == uiThreadId || !messageHwnd) { - task(); - return; - } - - auto done = std::make_shared(false); - auto doneMutex = std::make_shared(); - auto doneCv = std::make_shared(); - { - std::lock_guard lock(uiTaskMutex); - uiTasks.push_back([task = std::move(task), done, doneMutex, doneCv]() mutable { - task(); - { - std::lock_guard doneLock(*doneMutex); - *done = true; - } - doneCv->notify_one(); - }); - } - SendMessageW(messageHwnd, WM_NUVIO_TASK, 0, 0); - - std::unique_lock waitLock(*doneMutex); - doneCv->wait(waitLock, [&]() { return *done; }); - } - - void startWebView(const std::string &controlsUrl) { - std::wstring userDataDir = tempUserDataDirectory(); - auto weakSelf = weak_from_this(); - HRESULT result = CreateCoreWebView2EnvironmentWithOptions( - nullptr, - userDataDir.c_str(), - nullptr, - Callback( - [weakSelf, controlsUrl](HRESULT envResult, ICoreWebView2Environment *createdEnvironment) -> HRESULT { - auto self = weakSelf.lock(); - if (!self || self->shuttingDown.load()) return S_OK; - if (FAILED(envResult) || !createdEnvironment) { - return S_OK; - } - self->environment = createdEnvironment; - auto controllerWeakSelf = weakSelf; - HRESULT controllerResult = createdEnvironment->CreateCoreWebView2Controller( - self->containerHwnd, - Callback( - [controllerWeakSelf, controlsUrl](HRESULT controllerResult, ICoreWebView2Controller *createdController) -> HRESULT { - auto controllerSelf = controllerWeakSelf.lock(); - if (!controllerSelf || controllerSelf->shuttingDown.load()) return S_OK; - if (FAILED(controllerResult) || !createdController) { - return S_OK; - } - controllerSelf->controller = createdController; - createdController->get_CoreWebView2(&controllerSelf->webView); - - ComPtr controller2; - if (SUCCEEDED(createdController->QueryInterface(IID_PPV_ARGS(&controller2))) && controller2) { - COREWEBVIEW2_COLOR transparent = {0, 0, 0, 0}; - controller2->put_DefaultBackgroundColor(transparent); - } - - ComPtr settings; - if (controllerSelf->webView && SUCCEEDED(controllerSelf->webView->get_Settings(&settings)) && settings) { - settings->put_AreDefaultContextMenusEnabled(FALSE); - settings->put_IsStatusBarEnabled(FALSE); - } - - if (controllerSelf->webView) { - auto messageWeakSelf = controllerWeakSelf; - controllerSelf->webView->add_WebMessageReceived( - Callback( - [messageWeakSelf](ICoreWebView2 *, ICoreWebView2WebMessageReceivedEventArgs *args) -> HRESULT { - auto messageSelf = messageWeakSelf.lock(); - if (!messageSelf || messageSelf->shuttingDown.load() || !args) return S_OK; - PWSTR messageJson = nullptr; - if (SUCCEEDED(args->get_WebMessageAsJson(&messageJson)) && messageJson) { - messageSelf->handleWebMessage(std::wstring(messageJson)); - CoTaskMemFree(messageJson); - } - return S_OK; - } - ).Get(), - &controllerSelf->messageToken - ); - controllerSelf->layoutNativeSubviews(); - std::wstring url = toWide(controlsUrl); - controllerSelf->webView->Navigate(url.c_str()); - createdController->MoveFocus(COREWEBVIEW2_MOVE_FOCUS_REASON_PROGRAMMATIC); - } - return S_OK; - } - ).Get() - ); - (void)controllerResult; - return S_OK; - } - ).Get() - ); - if (FAILED(result)) { - throw std::runtime_error(hresultMessage("CreateCoreWebView2EnvironmentWithOptions", result)); - } - } - - void startMpv( - const std::string &sourceUrl, - const std::vector &headerLines, - bool playWhenReady, - long long initialPositionMs - ) { - MpvApi &api = mpvApi(); - { - std::lock_guard lock(mpvMutex); - mpv = api.create(); - if (!mpv) { - throw std::runtime_error("mpv_create failed."); - } - initialStartSeconds = initialPositionMs > 0 ? (double)initialPositionMs / 1000.0 : 0.0; - - setMpvOptionStringLocked("config", "no"); - setMpvOptionStringLocked("osc", "no"); - setMpvOptionStringLocked("input-default-bindings", "yes"); - setMpvOptionStringLocked("input-vo-keyboard", "no"); - setMpvOptionStringLocked("keep-open", "yes"); - setMpvOptionStringLocked("vo", "gpu-next"); - setMpvOptionStringLocked("gpu-api", "d3d11"); - setMpvOptionStringLocked("hwdec", "d3d11va"); - setMpvOptionStringLocked("hwdec-codecs", "all"); - setMpvOptionStringLocked("vd-lavc-software-fallback", "no"); - setMpvOptionStringLocked("vd-lavc-threads", "4"); - setMpvOptionStringLocked("target-colorspace-hint", "yes"); - setMpvOptionStringLocked("tone-mapping", "auto"); - setMpvOptionStringLocked("dither-depth", "auto"); - setMpvOptionStringLocked("deband", "yes"); - setMpvOptionStringLocked("scale", "spline36"); - setMpvOptionStringLocked("cscale", "spline36"); - setMpvOptionStringLocked("demuxer-max-bytes", "64MiB"); - setMpvOptionStringLocked("demuxer-max-back-bytes", "16MiB"); - setMpvOptionStringLocked("demuxer-seekable-cache", "no"); - setMpvOptionStringLocked("cache-secs", "30"); - setMpvOptionStringLocked("hr-seek", "no"); - - int64_t wid = (int64_t)(intptr_t)containerHwnd; - int widResult = api.setOption(mpv, "wid", MPV_FORMAT_INT64, &wid); - if (widResult < 0) { - throw std::runtime_error(std::string("mpv wid option failed: ") + api.errorText(widResult)); - } - - if (!headerLines.empty()) { - std::string headers; - for (size_t index = 0; index < headerLines.size(); index++) { - if (index > 0) headers.push_back(','); - headers += headerLines[index]; - } - setMpvOptionStringLocked("http-header-fields", headers.c_str()); - } - - int initResult = api.initialize(mpv); - if (initResult < 0) { - throw std::runtime_error(std::string("mpv_initialize failed: ") + api.errorText(initResult)); - } - - std::vector loadCommand = {"loadfile", sourceUrl.c_str()}; - std::string loadOptions; - if (initialPositionMs > 0) { - char startBuffer[64]; - std::snprintf(startBuffer, sizeof(startBuffer), "start=%.3f", (double)initialPositionMs / 1000.0); - loadOptions = startBuffer; - loadCommand.push_back("replace"); - loadCommand.push_back("-1"); - loadCommand.push_back(loadOptions.c_str()); - } - loadCommand.push_back(nullptr); - - int commandResult = api.command(mpv, loadCommand.data()); - if (commandResult < 0) { - throw std::runtime_error(std::string("mpv loadfile failed: ") + api.errorText(commandResult)); - } - } - - setPaused(!playWhenReady); - auto self = shared_from_this(); - eventThread = std::thread([self]() { self->drainMpvEvents(); }); - } - - void layoutNativeSubviews() { - if (!hostHwnd || !IsWindow(hostHwnd)) { - return; - } - RECT bounds = {}; - GetClientRect(hostHwnd, &bounds); - LONG width = std::max(1, bounds.right - bounds.left); - LONG height = std::max(1, bounds.bottom - bounds.top); - if (containerHwnd) { - SetWindowPos(containerHwnd, HWND_TOP, 0, 0, width, height, SWP_SHOWWINDOW | SWP_NOACTIVATE); - } - if (controller) { - RECT webBounds = {0, 0, width, height}; - controller->put_Bounds(webBounds); - controller->put_IsVisible(TRUE); - } - } - - void flushPendingControlsJsonIfReady() { - if (!webView || !controlsWebReady.load()) { - return; - } - std::string controlsJson; - { - std::lock_guard lock(controlsMutex); - controlsJson = pendingControlsJson; - pendingControlsJson.clear(); - } - if (controlsJson.empty()) return; - std::wstring script = - L"(function(){if(!window.playerControls)return 'missing';window.playerControls(JSON.parse(" + - javaScriptStringLiteral(controlsJson) + - L"));return 'applied';})()"; - webView->ExecuteScript(script.c_str(), nullptr); - } - - void syncControls() { - if (!webView) return; - double duration = doubleProperty("duration", 0.0); - double position = doubleProperty("time-pos", 0.0); - bool paused = isPaused(); - bool loading = isLoading(); - std::string audioTracks = audioTracksJson(); - std::string subtitleTracks = subtitleTracksJson(); - - std::ostringstream script; - script << "window.playerUpdate({duration:" << duration - << ",position:" << position - << ",paused:" << (paused ? "true" : "false") - << ",loading:" << (loading ? "true" : "false") - << ",audioTracks:" << audioTracks - << ",subtitleTracks:" << subtitleTracks - << "})"; - std::wstring wideScript = toWide(script.str()); - webView->ExecuteScript(wideScript.c_str(), nullptr); - } - - void handleWebMessage(const std::wstring &messageJson) { - std::string type = extractJsonString(messageJson, L"type"); - if (type.empty()) return; - double value = extractJsonNumber(messageJson, L"value", 0.0); - - if (type == "controlsReady") { - controlsWebReady.store(true); - flushPendingControlsJsonIfReady(); - syncControls(); - return; - } - if (type == "selectAudioTrack") { - selectAudioTrackId((int)std::llround(value)); - syncControls(); - return; - } - if (type == "selectSubtitleTrack") { - selectSubtitleTrackId((int)std::llround(value)); - syncControls(); - return; - } - sendPlayerEvent(type, value); - } - - static std::string extractJsonString(const std::wstring &json, const std::wstring &field) { - std::wstring key = L"\"" + field + L"\""; - size_t keyIndex = json.find(key); - if (keyIndex == std::wstring::npos) return std::string(); - size_t colon = json.find(L':', keyIndex + key.size()); - if (colon == std::wstring::npos) return std::string(); - size_t quote = json.find(L'"', colon + 1); - if (quote == std::wstring::npos) return std::string(); - - std::wstring result; - bool escaping = false; - for (size_t index = quote + 1; index < json.size(); index++) { - wchar_t ch = json[index]; - if (escaping) { - switch (ch) { - case L'"': result.push_back(L'"'); break; - case L'\\': result.push_back(L'\\'); break; - case L'/': result.push_back(L'/'); break; - case L'b': result.push_back(L'\b'); break; - case L'f': result.push_back(L'\f'); break; - case L'n': result.push_back(L'\n'); break; - case L'r': result.push_back(L'\r'); break; - case L't': result.push_back(L'\t'); break; - default: result.push_back(ch); break; - } - escaping = false; - continue; - } - if (ch == L'\\') { - escaping = true; - continue; - } - if (ch == L'"') break; - result.push_back(ch); - } - return toUtf8(result); - } - - static double extractJsonNumber(const std::wstring &json, const std::wstring &field, double fallback) { - std::wstring key = L"\"" + field + L"\""; - size_t keyIndex = json.find(key); - if (keyIndex == std::wstring::npos) return fallback; - size_t colon = json.find(L':', keyIndex + key.size()); - if (colon == std::wstring::npos) return fallback; - size_t start = json.find_first_not_of(L" \t\r\n", colon + 1); - if (start == std::wstring::npos) return fallback; - size_t end = start; - while (end < json.size()) { - wchar_t ch = json[end]; - if (!(std::iswdigit(ch) || ch == L'-' || ch == L'+' || ch == L'.' || ch == L'e' || ch == L'E')) { - break; - } - end++; - } - if (end <= start) return fallback; - try { - return std::stod(json.substr(start, end - start)); - } catch (...) { - return fallback; - } - } - - void drainMpvEvents() { - while (!stopping.load()) { - mpv_handle *current = nullptr; - { - std::lock_guard lock(mpvMutex); - current = mpv; - } - if (!current) { - return; - } - - mpv_event *event = mpvApi().waitEvent(current, 0.5); - if (!event) continue; - if (event->event_id == MPV_EVENT_SHUTDOWN) { - return; - } - } - } - - JNIEnv *jniEnvDidAttach(bool *didAttach) { - if (didAttach) *didAttach = false; - if (!javaVm) return nullptr; - JNIEnv *env = nullptr; - jint status = javaVm->GetEnv((void **)&env, JNI_VERSION_1_6); - if (status == JNI_OK) return env; - if (status != JNI_EDETACHED) return nullptr; - if (javaVm->AttachCurrentThread((void **)&env, nullptr) != JNI_OK) { - return nullptr; - } - if (didAttach) *didAttach = true; - return env; - } - - void sendPlayerEvent(const std::string &type, double value) { - if (!eventSink || !eventMethod) return; - bool didAttach = false; - JNIEnv *env = jniEnvDidAttach(&didAttach); - if (!env) return; - - jstring eventType = newJavaStringUtf8(env, type); - env->CallVoidMethod(eventSink, eventMethod, eventType, (jdouble)value); - if (env->ExceptionCheck()) { - env->ExceptionClear(); - } - if (eventType) { - env->DeleteLocalRef(eventType); - } - if (didAttach) { - javaVm->DetachCurrentThread(); - } - } - - void setMpvOptionStringLocked(const char *name, const char *value) { - (void)mpvApi().setOptionString(mpv, name, value); - } - - double doubleProperty(const char *name, double fallback) { - std::lock_guard lock(mpvMutex); - if (!mpv) return fallback; - double value = fallback; - int result = mpvApi().getProperty(mpv, name, MPV_FORMAT_DOUBLE, &value); - if (result < 0) { - return fallback; - } - return value; - } - - long long int64Property(const char *name, long long fallback) { - std::lock_guard lock(mpvMutex); - if (!mpv) return fallback; - int64_t value = fallback; - int result = mpvApi().getProperty(mpv, name, MPV_FORMAT_INT64, &value); - if (result < 0) { - return fallback; - } - return value; - } - - bool flagProperty(const char *name, bool fallback) { - std::lock_guard lock(mpvMutex); - if (!mpv) return fallback; - int flag = fallback ? 1 : 0; - int result = mpvApi().getProperty(mpv, name, MPV_FORMAT_FLAG, &flag); - if (result < 0) { - return fallback; - } - return flag != 0; - } - - std::string stringProperty(const char *name, const std::string &fallback) { - std::lock_guard lock(mpvMutex); - if (!mpv) return fallback; - char *value = nullptr; - int propertyResult = mpvApi().getProperty(mpv, name, MPV_FORMAT_STRING, &value); - if (propertyResult < 0 || !value) { - return fallback; - } - std::string result(value); - mpvApi().freeValue(value); - return result; - } - - void setStringProperty(const char *name, const std::string &value) { - std::lock_guard lock(mpvMutex); - if (!mpv) return; - mpvApi().setPropertyString(mpv, name, value.c_str()); - } - - void command(const std::vector &args) { - if (args.empty()) return; - std::lock_guard lock(mpvMutex); - if (!mpv) return; - std::vector cargs; - cargs.reserve(args.size() + 1); - for (const std::string &arg : args) { - cargs.push_back(arg.c_str()); - } - cargs.push_back(nullptr); - mpvApi().command(mpv, cargs.data()); - } - - double rawPositionSeconds() { - double position = doubleProperty("time-pos", 0.0); - return std::isfinite(position) ? std::max(position, 0.0) : 0.0; - } - - double effectiveCachePositionSeconds() { - double position = rawPositionSeconds(); - if (initialStartSeconds > 0.0 && position + 5.0 < initialStartSeconds) { - return initialStartSeconds; - } - return position; - } - - double cacheAheadSeconds() { - double effectivePosition = effectiveCachePositionSeconds(); - double cacheTime = doubleProperty("demuxer-cache-time", 0.0); - if (std::isfinite(cacheTime) && cacheTime > 0.0) { - if (cacheTime >= effectivePosition - 5.0) { - return std::max(cacheTime - effectivePosition, 0.0); - } - return cacheTime; - } - - double cacheDuration = doubleProperty("demuxer-cache-duration", 0.0); - if (std::isfinite(cacheDuration) && cacheDuration > 0.0) { - return cacheDuration; - } - return 0.0; - } - - void removeExternalSubtitleTracks() { - long long count = int64Property("track-list/count", 0); - if (count <= 0) return; - for (long long index = count - 1; index >= 0; index--) { - std::string prefix = "track-list/" + std::to_string(index); - std::string type = stringProperty((prefix + "/type").c_str(), ""); - bool external = flagProperty((prefix + "/external").c_str(), false); - if (type == "sub" && external) { - long long trackId = int64Property((prefix + "/id").c_str(), -1); - if (trackId >= 0) { - command({"sub-remove", std::to_string(trackId)}); - } - } - } - } - - std::string tracksJsonForType(const std::string &wantedType) { - long long count = int64Property("track-list/count", 0); - std::ostringstream json; - json << "["; - int logicalIndex = 0; - bool first = true; - for (long long index = 0; index < count; index++) { - std::string prefix = "track-list/" + std::to_string(index); - std::string type = stringProperty((prefix + "/type").c_str(), ""); - if (type != wantedType) continue; - - long long trackId = int64Property((prefix + "/id").c_str(), logicalIndex + 1); - std::string title = trackStringAtIndex(index, "title"); - std::string language = trackStringAtIndex(index, "lang"); - std::string codec = trackStringAtIndex(index, "codec"); - std::string decoderDescription = trackStringAtIndex(index, "decoder-desc"); - std::string channels = trackStringAtIndex(index, "demux-channels"); - long long channelCount = int64Property((prefix + "/demux-channel-count").c_str(), 0); - bool selected = flagProperty((prefix + "/selected").c_str(), false); - bool forced = flagProperty((prefix + "/forced").c_str(), false); - std::string label = formatTrackTitle(type, logicalIndex, title, language, codec, decoderDescription, channels, (int)channelCount); - - if (!first) json << ","; - first = false; - json << "{" - << "\"index\":" << logicalIndex << "," - << "\"id\":\"" << jsonEscape(std::to_string(trackId)) << "\"," - << "\"label\":\"" << jsonEscape(label) << "\"," - << "\"language\":\"" << jsonEscape(language) << "\"," - << "\"selected\":" << (selected ? "true" : "false") << "," - << "\"forced\":" << (forced ? "true" : "false") - << "}"; - logicalIndex++; - } - json << "]"; - return json.str(); - } - - std::string trackStringAtIndex(long long index, const std::string &field) { - return trim(stringProperty(("track-list/" + std::to_string(index) + "/" + field).c_str(), "")); - } - - std::string formatTrackTitle( - const std::string &type, - int index, - const std::string &title, - const std::string &language, - const std::string &codec, - const std::string &decoderDescription, - const std::string &channels, - int channelCount - ) { - std::string base = !trim(title).empty() - ? trim(title) - : (!trim(language).empty() - ? trim(language) - : (type == "sub" ? "Subtitle " + std::to_string(index + 1) : "Track " + std::to_string(index + 1))); - std::string codecName = codecDisplayName(codec); - if (codecName.empty()) codecName = codecDisplayName(decoderDescription); - std::string channelName = type == "audio" ? channelLayoutName(channels, channelCount) : ""; - - std::vector details; - for (const std::string &detail : {channelName, codecName}) { - if (!detail.empty() && !containsCaseInsensitive(base, detail)) { - details.push_back(detail); - } - } - if (details.empty()) return base; - std::string suffix; - for (size_t detailIndex = 0; detailIndex < details.size(); detailIndex++) { - if (detailIndex > 0) suffix += ", "; - suffix += details[detailIndex]; - } - return base + " (" + suffix + ")"; - } - - std::string channelLayoutName(const std::string &channels, int channelCount) { - std::string normalized = trim(channels); - if (!normalized.empty() && lowerCopy(normalized) != "unknown") { - std::string lower = lowerCopy(normalized); - if (lower == "mono") return "Mono"; - if (lower == "stereo") return "Stereo"; - return normalized; - } - switch (channelCount) { - case 1: return "Mono"; - case 2: return "Stereo"; - case 6: return "5.1"; - case 8: return "7.1"; - default: return channelCount > 0 ? std::to_string(channelCount) + "ch" : ""; - } - } - - std::string codecDisplayName(const std::string &value) { - std::string raw = trim(value); - if (raw.empty()) return ""; - std::string codec = lowerCopy(raw); - if (codec.find("eac3") != std::string::npos || codec.find("e-ac-3") != std::string::npos || codec.find("e ac-3") != std::string::npos) { - return codec.find("joc") != std::string::npos || codec.find("atmos") != std::string::npos ? "E-AC-3-JOC" : "E-AC-3"; - } - if (codec.find("truehd") != std::string::npos || codec.find("true hd") != std::string::npos) return "TrueHD"; - if (codec.find("ac3") != std::string::npos || codec.find("ac-3") != std::string::npos) return "AC-3"; - if (codec.find("dts-hd") != std::string::npos || codec.find("dtshd") != std::string::npos || codec.find("dts hd") != std::string::npos) return "DTS-HD"; - if (codec.find("dts") != std::string::npos || codec == "dca") return "DTS"; - if (codec.find("aac") != std::string::npos) return "AAC"; - if (codec.find("mp3") != std::string::npos || codec.find("mpeg audio") != std::string::npos) return "MP3"; - if (codec.find("mp2") != std::string::npos) return "MP2"; - if (codec.find("opus") != std::string::npos) return "Opus"; - if (codec.find("vorbis") != std::string::npos) return "Vorbis"; - if (codec.find("flac") != std::string::npos) return "FLAC"; - if (codec.find("alac") != std::string::npos) return "ALAC"; - if (codec.find("pcm") != std::string::npos || codec.find("wav") != std::string::npos) return "WAV"; - if (codec.find("pgs") != std::string::npos || codec.find("hdmv") != std::string::npos) return "PGS"; - if (codec.find("subrip") != std::string::npos || codec == "srt") return "SRT"; - if (codec.find("ass") != std::string::npos || codec.find("ssa") != std::string::npos) return "SSA"; - if (codec.find("webvtt") != std::string::npos || codec == "vtt") return "VTT"; - if (codec.find("ttml") != std::string::npos) return "TTML"; - if (codec.find("mov_text") != std::string::npos || codec.find("tx3g") != std::string::npos) return "TX3G"; - if (codec.find("dvb") != std::string::npos) return "DVB"; - return raw; - } -}; - -LRESULT CALLBACK messageWindowProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) { - if (message == WM_NCCREATE) { - auto *create = reinterpret_cast(lParam); - SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast(create->lpCreateParams)); - return TRUE; - } - auto *player = reinterpret_cast(GetWindowLongPtrW(hwnd, GWLP_USERDATA)); - switch (message) { - case WM_NUVIO_TASK: - if (player) player->processUiTasks(); - return 0; - case WM_TIMER: - if (player && wParam == NUVIO_TIMER_ID) player->onTimer(); - return 0; - case WM_NCDESTROY: - SetWindowLongPtrW(hwnd, GWLP_USERDATA, 0); - return 0; - default: - return DefWindowProcW(hwnd, message, wParam, lParam); - } -} - -LRESULT CALLBACK containerWindowProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) { - if (message == WM_NCCREATE) { - auto *create = reinterpret_cast(lParam); - SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast(create->lpCreateParams)); - return TRUE; - } - switch (message) { - case WM_SIZE: - return 0; - case WM_ERASEBKGND: { - RECT rect = {}; - GetClientRect(hwnd, &rect); - FillRect((HDC)wParam, &rect, (HBRUSH)GetStockObject(BLACK_BRUSH)); - return 1; - } - case WM_NCDESTROY: - SetWindowLongPtrW(hwnd, GWLP_USERDATA, 0); - return 0; - default: - return DefWindowProcW(hwnd, message, wParam, lParam); - } -} - -std::shared_ptr playerFromHandle(jlong handle) { - if (handle == 0) return nullptr; - auto *holder = reinterpret_cast *>(handle); - return holder ? *holder : nullptr; -} - -} // namespace - -BOOL APIENTRY DllMain(HMODULE module, DWORD reason, LPVOID) { - if (reason == DLL_PROCESS_ATTACH) { - gModule = module; - DisableThreadLibraryCalls(module); - } - return TRUE; -} - -extern "C" JNIEXPORT jlong JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_create( - JNIEnv *env, - jobject, - jlong hostViewPtr, - jstring sourceUrl, - jobjectArray headerLines, - jboolean playWhenReady, - jlong initialPositionMs, - jstring controlsPageUrl, - jobject eventSink -) { - HWND hostHwnd = (HWND)(intptr_t)hostViewPtr; - std::string sourceUrlText = jstringToUtf8(env, sourceUrl); - std::vector headerLineValues = jstringArrayToVector(env, headerLines); - std::string controlsPageUrlText = jstringToUtf8(env, controlsPageUrl); - JavaVM *javaVm = nullptr; - env->GetJavaVM(&javaVm); - - jobject eventSinkRef = nullptr; - jmethodID eventMethod = nullptr; - if (eventSink) { - eventSinkRef = env->NewGlobalRef(eventSink); - jclass eventSinkClass = env->GetObjectClass(eventSink); - eventMethod = env->GetMethodID(eventSinkClass, "onPlayerEvent", "(Ljava/lang/String;D)V"); - env->DeleteLocalRef(eventSinkClass); - if (!eventMethod) { - if (eventSinkRef) env->DeleteGlobalRef(eventSinkRef); - throwJavaError(env, "Native player event sink is missing onPlayerEvent(String, Double)."); - return 0; - } - } - - auto player = std::make_shared(); - try { - player->initialize( - hostHwnd, - sourceUrlText, - headerLineValues, - playWhenReady == JNI_TRUE, - initialPositionMs, - controlsPageUrlText, - javaVm, - eventSinkRef, - eventMethod - ); - } catch (const std::exception &error) { - player->shutdown(); - throwJavaError(env, error.what()); - return 0; - } - - auto *holder = new std::shared_ptr(player); - jlong handle = (jlong)(intptr_t)holder; - return handle; -} - -extern "C" JNIEXPORT jboolean JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_warmupWebView2(JNIEnv *env, jobject, jstring controlsPageUrl) { - std::string controlsPageUrlText = jstringToUtf8(env, controlsPageUrl); - return startWebView2Warmup(controlsPageUrlText) ? JNI_TRUE : JNI_FALSE; -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_shutdownWebView2Warmup(JNIEnv *, jobject) { - stopWebView2Warmup(); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_dispose(JNIEnv *, jobject, jlong handle) { - if (handle == 0) return; - auto *holder = reinterpret_cast *>(handle); - std::shared_ptr player = *holder; - delete holder; - if (player) player->shutdown(); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_updateControls(JNIEnv *env, jobject, jlong handle, jstring controlsJson) { - auto player = playerFromHandle(handle); - std::string controlsJsonText = jstringToUtf8(env, controlsJson); - if (player) player->updateControlsJson(controlsJsonText); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_setPaused(JNIEnv *, jobject, jlong handle, jboolean paused) { - auto player = playerFromHandle(handle); - if (player) player->setPaused(paused == JNI_TRUE); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_seekTo(JNIEnv *, jobject, jlong handle, jlong positionMs) { - auto player = playerFromHandle(handle); - if (player) player->seekToMilliseconds(positionMs); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_seekBy(JNIEnv *, jobject, jlong handle, jlong offsetMs) { - auto player = playerFromHandle(handle); - if (player) player->seekByMilliseconds(offsetMs); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_setSpeed(JNIEnv *, jobject, jlong handle, jfloat speed) { - auto player = playerFromHandle(handle); - if (player) player->setSpeed(speed); -} - -extern "C" JNIEXPORT jlong JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_durationMs(JNIEnv *, jobject, jlong handle) { - auto player = playerFromHandle(handle); - return player ? player->durationMs() : 0; -} - -extern "C" JNIEXPORT jlong JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_positionMs(JNIEnv *, jobject, jlong handle) { - auto player = playerFromHandle(handle); - return player ? player->positionMs() : 0; -} - -extern "C" JNIEXPORT jlong JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_bufferedPositionMs(JNIEnv *, jobject, jlong handle) { - auto player = playerFromHandle(handle); - return player ? player->bufferedPositionMs() : 0; -} - -extern "C" JNIEXPORT jboolean JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_isLoading(JNIEnv *, jobject, jlong handle) { - auto player = playerFromHandle(handle); - return player && player->isLoading() ? JNI_TRUE : JNI_FALSE; -} - -extern "C" JNIEXPORT jboolean JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_isEnded(JNIEnv *, jobject, jlong handle) { - auto player = playerFromHandle(handle); - return player && player->isEnded() ? JNI_TRUE : JNI_FALSE; -} - -extern "C" JNIEXPORT jboolean JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_isPaused(JNIEnv *, jobject, jlong handle) { - auto player = playerFromHandle(handle); - return !player || player->isPaused() ? JNI_TRUE : JNI_FALSE; -} - -extern "C" JNIEXPORT jfloat JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_speed(JNIEnv *, jobject, jlong handle) { - auto player = playerFromHandle(handle); - return player ? (jfloat)player->speed() : 1.0f; -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_setResizeMode(JNIEnv *, jobject, jlong handle, jint mode) { - auto player = playerFromHandle(handle); - if (player) player->setResizeMode(mode); -} - -extern "C" JNIEXPORT jstring JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_audioTracksJson(JNIEnv *env, jobject, jlong handle) { - auto player = playerFromHandle(handle); - return newJavaStringUtf8(env, player ? player->audioTracksJson() : "[]"); -} - -extern "C" JNIEXPORT jstring JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_subtitleTracksJson(JNIEnv *env, jobject, jlong handle) { - auto player = playerFromHandle(handle); - return newJavaStringUtf8(env, player ? player->subtitleTracksJson() : "[]"); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_selectAudioTrack(JNIEnv *, jobject, jlong handle, jint trackId) { - auto player = playerFromHandle(handle); - if (player) player->selectAudioTrackId(trackId); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_selectSubtitleTrack(JNIEnv *, jobject, jlong handle, jint trackId) { - auto player = playerFromHandle(handle); - if (player) player->selectSubtitleTrackId(trackId); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_addSubtitleUrl(JNIEnv *env, jobject, jlong handle, jstring url) { - auto player = playerFromHandle(handle); - if (player) player->addSubtitleUrl(jstringToUtf8(env, url)); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_clearExternalSubtitles(JNIEnv *, jobject, jlong handle) { - auto player = playerFromHandle(handle); - if (player) player->removeExternalSubtitles(); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_clearExternalSubtitlesAndSelect(JNIEnv *, jobject, jlong handle, jint trackId) { - auto player = playerFromHandle(handle); - if (player) player->removeExternalSubtitlesAndSelect(trackId); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_applyWindowChrome( - JNIEnv *, - jobject, - jlong windowHwnd, - jboolean darkMode, - jint captionColorRgb, - jint borderColorRgb, - jint textColorRgb -) { - applyDwmWindowChrome( - (HWND)(intptr_t)windowHwnd, - darkMode == JNI_TRUE, - rgbIntToColorRef(captionColorRgb), - rgbIntToColorRef(borderColorRgb), - rgbIntToColorRef(textColorRgb) - ); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_setSubtitleDelayMs(JNIEnv *, jobject, jlong handle, jint delayMs) { - auto player = playerFromHandle(handle); - if (player) player->setSubtitleDelayMs(delayMs); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_applySubtitleStyle( - JNIEnv *env, - jobject, - jlong handle, - jstring textColor, - jstring backgroundColor, - jstring outlineColor, - jfloat outlineSize, - jboolean bold, - jfloat fontSize, - jint subPos -) { - auto player = playerFromHandle(handle); - if (!player) return; - player->applySubtitleStyle( - jstringToUtf8(env, textColor), - jstringToUtf8(env, backgroundColor), - jstringToUtf8(env, outlineColor), - outlineSize, - bold == JNI_TRUE, - fontSize, - subPos - ); -} diff --git a/composeApp/src/desktopMain/resources/icons/nuvio-app-icon.icns b/composeApp/src/desktopMain/resources/icons/nuvio-app-icon.icns deleted file mode 100644 index 2c68eb9cbfbb98df87bd988474ebad96b57e7d0d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 209987 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

b4qAvfm) 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 E1B0|hKmY&$ diff --git a/composeApp/src/desktopMain/resources/icons/nuvio-app-icon.ico b/composeApp/src/desktopMain/resources/icons/nuvio-app-icon.ico deleted file mode 100644 index edcfdfcc30ce9b65e12adcd5b1e5b614ea5711f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 115246 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�S!7UKs==_g8Y3;#-bFg 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 S - - - - - - - - - -

- - - - diff --git a/composeApp/src/desktopMain/resources/player-ui/controls.js b/composeApp/src/desktopMain/resources/player-ui/controls.js deleted file mode 100644 index 2bb63670..00000000 --- a/composeApp/src/desktopMain/resources/player-ui/controls.js +++ /dev/null @@ -1,2196 +0,0 @@ -const root = document.getElementById("playerRoot"); -const seek = document.getElementById("seek"); -const positionLabel = document.getElementById("position"); -const durationLabel = document.getElementById("duration"); -const bufferingStatus = document.getElementById("bufferingStatus"); -const playbackError = document.getElementById("playbackError"); -const playbackErrorTitle = document.getElementById("playbackErrorTitle"); -const playbackErrorMessage = document.getElementById("playbackErrorMessage"); -const playbackErrorAction = document.getElementById("playbackErrorAction"); -const playbackErrorActionLabel = document.getElementById("playbackErrorActionLabel"); -const pauseMetadataOverlay = document.getElementById("pauseMetadataOverlay"); -const pauseWatchingLabel = document.getElementById("pauseWatchingLabel"); -const pauseLogo = document.getElementById("pauseLogo"); -const pauseTitle = document.getElementById("pauseTitle"); -const pauseEpisodeInfo = document.getElementById("pauseEpisodeInfo"); -const pauseEpisodeTitle = document.getElementById("pauseEpisodeTitle"); -const pauseDescription = document.getElementById("pauseDescription"); -const toggle = document.getElementById("toggle"); -const toggleIcon = document.getElementById("toggleIcon"); -const lockIcon = document.getElementById("lockIcon"); -const title = document.getElementById("title"); -const episode = document.getElementById("episode"); -const streamTitle = document.getElementById("streamTitle"); -const providerName = document.getElementById("providerName"); -const resizeLabel = document.getElementById("resizeLabel"); -const speedLabel = document.getElementById("speedLabel"); -const subtitlesLabel = document.getElementById("subtitlesLabel"); -const audioLabel = document.getElementById("audioLabel"); -const sourcesLabel = document.getElementById("sourcesLabel"); -const episodesLabel = document.getElementById("episodesLabel"); -const submitIntroButton = document.getElementById("submitIntroButton"); -const lockButton = document.getElementById("lockButton"); -const videoSettingsButton = document.getElementById("videoSettingsButton"); -const backButton = document.getElementById("backButton"); -const openingOverlay = document.getElementById("openingOverlay"); -const openingArtwork = document.getElementById("openingArtwork"); -const openingBackButton = document.getElementById("openingBackButton"); -const openingLogoSlot = document.getElementById("openingLogoSlot"); -const openingLogoBase = document.getElementById("openingLogoBase"); -const openingLogoFillClip = document.getElementById("openingLogoFillClip"); -const openingLogoFill = document.getElementById("openingLogoFill"); -const openingTitle = document.getElementById("openingTitle"); -const openingSpinner = document.getElementById("openingSpinner"); -const openingStatus = document.getElementById("openingStatus"); -const openingMessage = document.getElementById("openingMessage"); -const openingProgressTrack = document.getElementById("openingProgressTrack"); -const openingProgressBar = document.getElementById("openingProgressBar"); -const parentalGuide = document.getElementById("parentalGuide"); -const parentalGuideLine = document.getElementById("parentalGuideLine"); -const parentalGuideList = document.getElementById("parentalGuideList"); -const skipPrompt = document.getElementById("skipPrompt"); -const skipPromptLabel = document.getElementById("skipPromptLabel"); -const skipPromptProgress = document.getElementById("skipPromptProgress"); -const nextEpisodeCard = document.getElementById("nextEpisodeCard"); -const nextEpisodeThumb = document.getElementById("nextEpisodeThumb"); -const nextEpisodeHeader = document.getElementById("nextEpisodeHeader"); -const nextEpisodeTitle = document.getElementById("nextEpisodeTitle"); -const nextEpisodeStatus = document.getElementById("nextEpisodeStatus"); -const nextEpisodeAction = document.getElementById("nextEpisodeAction"); -const sourcesButton = document.getElementById("sourcesButton"); -const episodesButton = document.getElementById("episodesButton"); -const lockedLabel = document.getElementById("lockedLabel"); -const audioModal = document.getElementById("audioModal"); -const subtitleModal = document.getElementById("subtitleModal"); -const audioTrackList = document.getElementById("audioTrackList"); -const subtitleTrackList = document.getElementById("subtitleTrackList"); -const subtitlePanelTitle = document.getElementById("subtitlePanelTitle"); -const subtitleBuiltInTab = document.getElementById("subtitleBuiltInTab"); -const subtitleAddonsTab = document.getElementById("subtitleAddonsTab"); -const subtitleStyleTab = document.getElementById("subtitleStyleTab"); -const addonSubtitleList = document.getElementById("addonSubtitleList"); -const subtitleStylePanel = document.getElementById("subtitleStylePanel"); -const subtitleDelayLabel = document.getElementById("subtitleDelayLabel"); -const subtitleDelayMinus = document.getElementById("subtitleDelayMinus"); -const subtitleDelayValue = document.getElementById("subtitleDelayValue"); -const subtitleDelayPlus = document.getElementById("subtitleDelayPlus"); -const subtitleDelayReset = document.getElementById("subtitleDelayReset"); -const autoSyncLabel = document.getElementById("autoSyncLabel"); -const autoSyncReload = document.getElementById("autoSyncReload"); -const autoSyncCapture = document.getElementById("autoSyncCapture"); -const autoSyncStatus = document.getElementById("autoSyncStatus"); -const autoSyncCueList = document.getElementById("autoSyncCueList"); -const fontSizeLabel = document.getElementById("fontSizeLabel"); -const fontSizeMinus = document.getElementById("fontSizeMinus"); -const fontSizeValue = document.getElementById("fontSizeValue"); -const fontSizePlus = document.getElementById("fontSizePlus"); -const outlineLabel = document.getElementById("outlineLabel"); -const outlineToggle = document.getElementById("outlineToggle"); -const boldLabel = document.getElementById("boldLabel"); -const boldToggle = document.getElementById("boldToggle"); -const bottomOffsetLabel = document.getElementById("bottomOffsetLabel"); -const bottomOffsetMinus = document.getElementById("bottomOffsetMinus"); -const bottomOffsetValue = document.getElementById("bottomOffsetValue"); -const bottomOffsetPlus = document.getElementById("bottomOffsetPlus"); -const subtitleColorLabel = document.getElementById("subtitleColorLabel"); -const subtitleColorSwatches = document.getElementById("subtitleColorSwatches"); -const textOpacityLabel = document.getElementById("textOpacityLabel"); -const textOpacityMinus = document.getElementById("textOpacityMinus"); -const textOpacityValue = document.getElementById("textOpacityValue"); -const textOpacityPlus = document.getElementById("textOpacityPlus"); -const outlineColorLabel = document.getElementById("outlineColorLabel"); -const outlineColorSwatches = document.getElementById("outlineColorSwatches"); -const subtitleStyleReset = document.getElementById("subtitleStyleReset"); -const sourceModal = document.getElementById("sourceModal"); -const sourcePanelTitle = document.getElementById("sourcePanelTitle"); -const sourceReloadButton = document.getElementById("sourceReloadButton"); -const sourceCloseButton = document.getElementById("sourceCloseButton"); -const sourceFilterList = document.getElementById("sourceFilterList"); -const sourceList = document.getElementById("sourceList"); -const episodesModal = document.getElementById("episodesModal"); -const episodeListView = document.getElementById("episodeListView"); -const episodeStreamsView = document.getElementById("episodeStreamsView"); -const episodesPanelTitle = document.getElementById("episodesPanelTitle"); -const episodesCloseButton = document.getElementById("episodesCloseButton"); -const seasonFilterList = document.getElementById("seasonFilterList"); -const episodeList = document.getElementById("episodeList"); -const streamsPanelTitle = document.getElementById("streamsPanelTitle"); -const episodeBackButton = document.getElementById("episodeBackButton"); -const episodeReloadButton = document.getElementById("episodeReloadButton"); -const episodeStreamsCloseButton = document.getElementById("episodeStreamsCloseButton"); -const episodeStreamFilterList = document.getElementById("episodeStreamFilterList"); -const episodeStreamList = document.getElementById("episodeStreamList"); -const submitIntroModal = document.getElementById("submitIntroModal"); -const submitIntroPanelTitle = document.getElementById("submitIntroPanelTitle"); -const submitIntroCloseButton = document.getElementById("submitIntroCloseButton"); -const segmentTypeLabel = document.getElementById("segmentTypeLabel"); -const segmentIntroButton = document.getElementById("segmentIntroButton"); -const segmentRecapButton = document.getElementById("segmentRecapButton"); -const segmentOutroButton = document.getElementById("segmentOutroButton"); -const startTimeLabel = document.getElementById("startTimeLabel"); -const endTimeLabel = document.getElementById("endTimeLabel"); -const submitIntroStartInput = document.getElementById("submitIntroStartInput"); -const submitIntroEndInput = document.getElementById("submitIntroEndInput"); -const captureStartButton = document.getElementById("captureStartButton"); -const captureEndButton = document.getElementById("captureEndButton"); -const submitIntroStatus = document.getElementById("submitIntroStatus"); -const submitIntroCancelButton = document.getElementById("submitIntroCancelButton"); -const submitIntroSubmitButton = document.getElementById("submitIntroSubmitButton"); -const p2pConsentModal = document.getElementById("p2pConsentModal"); -const p2pConsentTitle = document.getElementById("p2pConsentTitle"); -const p2pConsentCloseButton = document.getElementById("p2pConsentCloseButton"); -const p2pConsentBody = document.getElementById("p2pConsentBody"); -const p2pConsentCancelButton = document.getElementById("p2pConsentCancelButton"); -const p2pConsentEnableButton = document.getElementById("p2pConsentEnableButton"); - -let state = { - title: "", - episodeText: "", - streamTitle: "", - providerName: "", - pauseOverlayWatchingLabel: "You're watching", - pauseOverlayLogo: "", - pauseOverlayEpisodeInfo: "", - pauseOverlayEpisodeTitle: "", - pauseOverlayDescription: "", - resizeModeLabel: "Fit", - playbackSpeedLabel: "1x", - subtitlesLabel: "Subs", - audioLabel: "Audio", - sourcesLabel: "Sources", - episodesLabel: "Episodes", - externalPlayerLabel: "External", - playLabel: "Play", - pauseLabel: "Pause", - closeLabel: "Close player", - lockLabel: "Lock player controls", - unlockLabel: "Unlock player controls", - submitIntroLabel: "Submit Intro", - videoSettingsLabel: "Video settings", - tapToUnlockLabel: "Tap to unlock", - playbackErrorTitle: "Playback error", - playbackErrorMessage: "", - playbackErrorActionLabel: "Go back", - sourcesPanelTitle: "Sources", - episodesPanelTitle: "Episodes", - streamsPanelTitle: "Streams", - allFilterLabel: "All", - reloadLabel: "Reload", - backLabel: "Back", - panelCloseLabel: "Close", - cancelLabel: "Cancel", - playingLabel: "Playing", - noStreamsLabel: "No streams found", - noEpisodesLabel: "No episodes available", - submitIntroPanelTitle: "Submit Timestamps", - submitIntroSegmentTypeLabel: "SEGMENT TYPE", - submitIntroSegmentIntroLabel: "Intro", - submitIntroSegmentRecapLabel: "Recap", - submitIntroSegmentOutroLabel: "Outro", - submitIntroStartTimeLabel: "START TIME (MM:SS)", - submitIntroEndTimeLabel: "END TIME (MM:SS)", - submitIntroCaptureLabel: "Capture", - submitIntroSubmitLabel: "Submit", - p2pConsentTitle: "P2P Streaming", - p2pConsentBody: "", - p2pConsentEnableLabel: "Enable P2P", - p2pConsentCancelLabel: "Cancel", - subtitlesPanelTitle: "Subtitles", - subtitleBuiltInTabLabel: "Built-in", - subtitleAddonsTabLabel: "Addons", - subtitleStyleTabLabel: "Style", - noneLabel: "None", - fetchSubtitlesLabel: "Tap to fetch subtitles", - subtitleDelayLabel: "Subtitle Delay", - resetLabel: "Reset", - autoSyncLabel: "Auto Sync", - reloadSmallLabel: "Reload", - captureLineLabel: "Capture", - selectAddonSubtitleFirstLabel: "Select an addon subtitle first", - loadingSubtitleLinesLabel: "Loading subtitle lines...", - fontSizeLabel: "Font Size", - outlineLabel: "Outline", - boldLabel: "Bold", - bottomOffsetLabel: "Bottom Offset", - colorLabel: "Color", - textOpacityLabel: "Text Opacity", - outlineColorLabel: "Outline Color", - resetDefaultsLabel: "Reset Defaults", - onLabel: "On", - offLabel: "Off", - themeAccentColor: "#2f6fed", - themeAccentStrongColor: "#3c7bff", - themeOnAccentColor: "#fff", - themeFocusColor: "#9ecaff", - themeSelectedSurfaceColor: "#26384f", - themeSelectedSurfaceHoverColor: "#2d4565", - themeSelectedRingColor: "rgba(47, 111, 237, .35)", - themeTimelineFillColor: "#fff", - themeTimelineTrackColor: "rgba(255, 255, 255, .28)", - themeBufferingColor: "#fff", - themeBufferingTrackColor: "rgba(255, 255, 255, .28)", - themeControlForegroundColor: "#fff", - isPlaying: false, - isLoading: true, - isLocked: false, - lockedOverlayVisible: false, - controlsVisible: true, - parentalWarnings: [], - showParentalGuide: false, - showOpeningOverlay: false, - openingArtwork: "", - openingLogo: "", - openingTitle: "", - openingMessage: "", - openingProgress: null, - skipPromptVisible: false, - skipPromptLabel: "Skip", - skipPromptStartMs: 0, - skipPromptEndMs: 0, - skipPromptDismissed: false, - nextEpisodeVisible: false, - nextEpisodeHeaderLabel: "Next episode", - nextEpisodeTitle: "", - nextEpisodeThumbnail: "", - nextEpisodeStatus: "", - nextEpisodeActionLabel: "Play", - nextEpisodePlayable: false, - showSubmitIntro: false, - showVideoSettings: false, - showSources: false, - showEpisodes: false, - showExternalPlayer: false, - durationMs: 0, - positionMs: 0, - audioTracks: [], - subtitleTracks: [], - sourceIsLoading: false, - sourceFilters: [], - sourceItems: [], - episodeItems: [], - episodeSeasons: [], - episodeStreamsVisible: false, - episodeStreamsIsLoading: false, - selectedEpisodeLabel: "", - episodeStreamFilters: [], - episodeStreamItems: [], - submitIntroSegmentType: "intro", - submitIntroStartTime: "00:00", - submitIntroEndTime: "00:00", - isSubmitIntroSubmitting: false, - submitIntroStatusMessage: "", - showP2pConsent: false, - subtitleActiveTab: "BuiltIn", - addonSubtitleItems: [], - isLoadingAddonSubtitles: false, - selectedAddonSubtitleId: "", - useCustomSubtitles: false, - subtitleDelayMs: 0, - hasSelectedAddonSubtitle: false, - subtitleAutoSyncCapturedPositionMs: -1, - subtitleAutoSyncCues: [], - subtitleAutoSyncIsLoading: false, - subtitleAutoSyncErrorMessage: "", - subtitleStyle: { - textColor: "#FFFFFFFF", - outlineColor: "#FF000000", - outlineEnabled: true, - bold: false, - fontSizeSp: 18, - bottomOffset: 20, - }, - subtitleColorSwatches: [], - closeModalsToken: 0, -}; -let isScrubbing = false; -let scrubPositionMs = 0; -let tapTimer = 0; -let activeModal = ""; -let pressedButton = null; -let sourceFilterId = ""; -let sourceVirtualKey = ""; -let sourceVirtualItems = []; -let sourceVirtualHeights = []; -let sourceVirtualOffsets = []; -let sourceVirtualTotalHeight = 0; -let sourceVirtualSpacer = null; -let sourceVirtualRenderRaf = 0; -let selectedEpisodeSeason = null; -let episodeStreamFilterId = ""; -let submitIntroDraft = { - segmentType: "intro", - startTime: "00:00", - endTime: "00:00", - status: "", -}; -let hasReceivedPlayerControls = false; -let parentalGuideRunId = 0; -let parentalGuideStartedKey = ""; -let parentalGuideCompletedKey = ""; -let skipPromptKey = ""; -let skipPromptWasDismissed = false; -let skipPromptAutoHidden = false; -let skipPromptAutoHideTimer = 0; -let skipPromptAutoHideActive = false; -let pauseMetadataReady = false; -let pauseMetadataTimer = 0; -let pauseMetadataEligibilityKey = ""; -let chromeAutoHideTimer = 0; -let chromeAutoHideKey = ""; -let chromeAutoHideActivity = 0; -let chromeInteractionLastNotedAt = 0; -let isChromePointerInside = false; -let isChromePointerDown = false; -let isChromeFocusInside = false; -let nativeViewportTimer = 0; -const prefersReducedMotion = window.matchMedia && - window.matchMedia("(prefers-reduced-motion: reduce)").matches; -const modalTransitionMs = prefersReducedMotion ? 1 : 240; -const chromeAutoHideDelayMs = 3500; -const chromeActivityThrottleMs = 300; -const chromeInteractionSelector = [ - "button", - "input", - "textarea", - "select", - "[contenteditable='true']", - ".header-actions", - ".center-controls", - ".progress", - ".locked-overlay", - ".modal-layer", - ".skip-prompt", - ".next-episode-card", -].join(","); - -const send = (type, value = 0) => { - const bridge = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.player; - if (bridge) { - bridge.postMessage({ type, value }); - return; - } - const webViewBridge = window.chrome && window.chrome.webview; - if (webViewBridge) webViewBridge.postMessage({ type, value }); -}; - -const animationDelay = ms => new Promise(resolve => { - window.setTimeout(resolve, prefersReducedMotion ? 1 : ms); -}); - -const normalizedParentalWarnings = () => - Array.isArray(state.parentalWarnings) - ? state.parentalWarnings - .map(warning => ({ - label: String(warning && warning.label || "").trim(), - severity: String(warning && warning.severity || "").trim(), - })) - .filter(warning => warning.label || warning.severity) - .slice(0, 5) - : []; - -const parentalWarningKey = warnings => - warnings.map(warning => `${warning.label}\u0000${warning.severity}`).join("\u0001"); - -const hideParentalGuide = () => { - root.classList.remove("parental-visible", "parental-line-visible"); - parentalGuide.setAttribute("aria-hidden", "true"); - parentalGuideList.querySelectorAll(".parental-guide-row").forEach(row => { - row.classList.remove("visible"); - }); -}; - -const renderParentalGuideRows = warnings => { - parentalGuideList.innerHTML = ""; - const rowHeight = 18; - const rowGap = 2; - const totalHeight = warnings.length > 0 - ? (rowHeight * warnings.length) + (rowGap * (warnings.length - 1)) - : 0; - parentalGuideLine.style.height = `${totalHeight}px`; - - warnings.forEach(warning => { - const row = document.createElement("div"); - row.className = "parental-guide-row"; - - const label = document.createElement("span"); - label.className = "parental-guide-label"; - label.textContent = warning.label; - - const separator = document.createElement("span"); - separator.className = "parental-guide-separator"; - separator.textContent = " · "; - - const severity = document.createElement("span"); - severity.className = "parental-guide-severity"; - severity.textContent = warning.severity; - - row.appendChild(label); - row.appendChild(separator); - row.appendChild(severity); - parentalGuideList.appendChild(row); - }); -}; - -const runParentalGuideAnimation = async (warnings, key, runId) => { - renderParentalGuideRows(warnings); - parentalGuide.setAttribute("aria-hidden", "false"); - root.classList.add("parental-visible"); - await animationDelay(300); - if (runId !== parentalGuideRunId) return; - - root.classList.add("parental-line-visible"); - await animationDelay(400); - if (runId !== parentalGuideRunId) return; - - const rows = Array.from(parentalGuideList.querySelectorAll(".parental-guide-row")); - for (const row of rows) { - await animationDelay(80); - if (runId !== parentalGuideRunId) return; - row.classList.add("visible"); - await animationDelay(200); - if (runId !== parentalGuideRunId) return; - } - - await animationDelay(5000); - if (runId !== parentalGuideRunId) return; - - for (const row of rows.slice().reverse()) { - await animationDelay(60); - if (runId !== parentalGuideRunId) return; - row.classList.remove("visible"); - await animationDelay(150); - if (runId !== parentalGuideRunId) return; - } - - await animationDelay(100); - if (runId !== parentalGuideRunId) return; - root.classList.remove("parental-line-visible"); - - await animationDelay(300); - if (runId !== parentalGuideRunId) return; - - await animationDelay(200); - if (runId !== parentalGuideRunId) return; - root.classList.remove("parental-visible"); - await animationDelay(300); - if (runId !== parentalGuideRunId) return; - parentalGuide.setAttribute("aria-hidden", "true"); - parentalGuideCompletedKey = key; - send("parentalGuideComplete", 0); -}; - -const syncParentalGuide = showOpening => { - const warnings = normalizedParentalWarnings(); - const shouldShow = Boolean(state.showParentalGuide && warnings.length && !showOpening && !state.isLocked); - if (!shouldShow) { - parentalGuideRunId += 1; - parentalGuideStartedKey = ""; - if (!state.showParentalGuide) { - parentalGuideCompletedKey = ""; - } - hideParentalGuide(); - return; - } - - const key = parentalWarningKey(warnings); - if (parentalGuideStartedKey === key || parentalGuideCompletedKey === key) return; - parentalGuideStartedKey = key; - parentalGuideRunId += 1; - runParentalGuideAnimation(warnings, key, parentalGuideRunId); -}; - -const cssColorOrFallback = (value, fallback) => { - const text = String(value || "").trim(); - return /^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\))$/.test(text) ? text : fallback; -}; - -const applyTheme = () => { - const style = document.documentElement.style; - const setColor = (name, value, fallback) => { - style.setProperty(name, cssColorOrFallback(value, fallback)); - }; - setColor("--theme-accent", state.themeAccentColor, "#2f6fed"); - setColor("--theme-accent-strong", state.themeAccentStrongColor, "#3c7bff"); - setColor("--theme-on-accent", state.themeOnAccentColor, "#fff"); - setColor("--theme-focus", state.themeFocusColor, "#9ecaff"); - setColor("--theme-selected-surface", state.themeSelectedSurfaceColor, "#26384f"); - setColor("--theme-selected-surface-hover", state.themeSelectedSurfaceHoverColor, "#2d4565"); - setColor("--theme-selected-ring", state.themeSelectedRingColor, "rgba(47, 111, 237, .35)"); - setColor("--theme-timeline-fill", state.themeTimelineFillColor, "#fff"); - setColor("--theme-timeline-track", state.themeTimelineTrackColor, "rgba(255, 255, 255, .28)"); - setColor("--theme-buffering", state.themeBufferingColor, "#fff"); - setColor("--theme-buffering-track", state.themeBufferingTrackColor, "rgba(255, 255, 255, .28)"); - setColor("--theme-control-foreground", state.themeControlForegroundColor, "#fff"); -}; - -const formatTime = milliseconds => { - if (!Number.isFinite(milliseconds) || milliseconds < 0) milliseconds = 0; - const total = Math.floor(milliseconds / 1000); - const h = Math.floor(total / 3600); - const m = Math.floor((total % 3600) / 60); - const s = total % 60; - return h > 0 - ? `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` - : `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; -}; - -const setProgress = (positionMs, durationMs) => { - const percent = durationMs > 0 ? Math.max(0, Math.min(100, positionMs / durationMs * 100)) : 0; - seek.value = Math.round(percent * 10); - seek.style.setProperty("--progress", `${percent}%`); - positionLabel.textContent = formatTime(positionMs); - durationLabel.textContent = formatTime(durationMs); -}; - -const setText = (element, text) => { - element.textContent = text || ""; - element.hidden = !text; -}; - -const setVisible = (element, visible) => { - element.hidden = !visible; -}; - -const setImageVisualState = (element, stateName) => { - const frame = element.parentElement; - [element, frame].filter(Boolean).forEach(target => { - target.classList.remove("image-loading", "image-loaded", "image-error"); - if (stateName) target.classList.add(`image-${stateName}`); - }); -}; - -const setImageSource = (element, source) => { - const url = String(source || "").trim(); - if (!url) { - element.removeAttribute("src"); - element.removeAttribute("data-loaded-src"); - setImageVisualState(element, ""); - return ""; - } - const currentUrl = element.getAttribute("src") || ""; - const loadedUrl = element.getAttribute("data-loaded-src") || ""; - if (currentUrl !== url) { - element.setAttribute("decoding", "async"); - setImageVisualState(element, "loading"); - element.onload = () => { - if (element.getAttribute("src") !== url) return; - element.setAttribute("data-loaded-src", url); - window.requestAnimationFrame(() => setImageVisualState(element, "loaded")); - }; - element.onerror = () => { - if (element.getAttribute("src") !== url) return; - element.removeAttribute("data-loaded-src"); - setImageVisualState(element, "error"); - }; - element.setAttribute("src", url); - if (element.complete && element.naturalWidth > 0) { - element.onload(); - } - } else if (loadedUrl === url) { - setImageVisualState(element, "loaded"); - } - return url; -}; - -const resetPauseMetadataTimer = () => { - window.clearTimeout(pauseMetadataTimer); - pauseMetadataTimer = 0; - pauseMetadataReady = false; - pauseMetadataEligibilityKey = ""; -}; - -const syncPauseMetadataTimer = showOpening => { - const durationMs = Math.max(0, Number(state.durationMs) || 0); - const eligible = Boolean(!state.isPlaying && !state.isLoading && durationMs > 0 && !showOpening); - const key = eligible ? `${Math.round(durationMs)}:${state.title || ""}:${state.pauseOverlayEpisodeInfo || ""}` : ""; - if (!eligible) { - resetPauseMetadataTimer(); - return; - } - if (pauseMetadataEligibilityKey === key) return; - window.clearTimeout(pauseMetadataTimer); - pauseMetadataReady = false; - pauseMetadataEligibilityKey = key; - pauseMetadataTimer = window.setTimeout(() => { - pauseMetadataTimer = 0; - pauseMetadataReady = true; - renderChrome(); - }, prefersReducedMotion ? 1 : 5000); -}; - -const renderPauseMetadataOverlay = showOpening => { - syncPauseMetadataTimer(showOpening); - - const logoUrl = setImageSource(pauseLogo, state.pauseOverlayLogo); - const titleText = String(state.title || "").trim(); - const episodeInfo = String(state.pauseOverlayEpisodeInfo || state.providerName || "").trim(); - const episodeTitleText = String(state.pauseOverlayEpisodeTitle || "").trim(); - const descriptionText = String(state.pauseOverlayDescription || "").trim(); - const showOverlay = Boolean( - pauseMetadataReady && - !state.controlsVisible && - !state.isLocked && - !activeModal && - !showOpening, - ); - - pauseWatchingLabel.textContent = state.pauseOverlayWatchingLabel || "You're watching"; - pauseLogo.hidden = !logoUrl; - pauseTitle.textContent = titleText; - pauseTitle.hidden = Boolean(logoUrl || !titleText); - pauseEpisodeInfo.textContent = episodeInfo; - pauseEpisodeInfo.hidden = !episodeInfo; - pauseEpisodeTitle.textContent = episodeTitleText; - pauseEpisodeTitle.hidden = !episodeTitleText; - pauseDescription.textContent = descriptionText; - pauseDescription.hidden = !descriptionText; - pauseMetadataOverlay.classList.toggle("visible", showOverlay); - pauseMetadataOverlay.setAttribute("aria-hidden", showOverlay ? "false" : "true"); -}; - -const normalizedOpeningProgress = () => { - const progress = Number(state.openingProgress); - return Number.isFinite(progress) ? Math.max(0, Math.min(1, progress)) : null; -}; - -const playbackErrorText = () => String(state.playbackErrorMessage || "").trim(); - -const rangePositionMs = () => { - const durationMs = Math.max(0, Number(state.durationMs) || 0); - return durationMs > 0 ? Math.round(durationMs * Number(seek.value) / 1000) : 0; -}; - -const modalByName = { - audio: audioModal, - subtitles: subtitleModal, - sources: sourceModal, - episodes: episodesModal, - submitIntro: submitIntroModal, - p2pConsent: p2pConsentModal, -}; -const modalElements = Object.values(modalByName); -const modalCloseTimers = new Map(); - -const setModalVisibility = (modal, visible, animated = true) => { - const pendingTimer = modalCloseTimers.get(modal); - if (pendingTimer) { - window.clearTimeout(pendingTimer); - modalCloseTimers.delete(modal); - } - if (visible) { - modal.dataset.modalState = "open"; - modal.hidden = false; - modal.classList.remove("modal-closing"); - window.requestAnimationFrame(() => { - if (modal.dataset.modalState === "open") { - modal.classList.add("modal-visible"); - } - }); - return; - } - - modal.dataset.modalState = "closed"; - modal.classList.remove("modal-visible"); - if (!animated || modal.hidden) { - modal.hidden = true; - modal.classList.remove("modal-closing"); - return; - } - - modal.classList.add("modal-closing"); - const timer = window.setTimeout(() => { - modalCloseTimers.delete(modal); - if (modal.dataset.modalState === "closed") { - modal.hidden = true; - modal.classList.remove("modal-closing"); - } - }, modalTransitionMs); - modalCloseTimers.set(modal, timer); -}; - -const closePlayerModal = (notifyDismiss = false, animated = true) => { - const closingModal = activeModal; - activeModal = ""; - modalElements.forEach(modal => { - setModalVisibility(modal, false, animated); - }); - if (notifyDismiss && closingModal === "p2pConsent") { - send("cancelP2pForPlayerControls", 0); - } - renderChrome(); -}; - -const openPlayerModal = modal => { - const targetModal = modalByName[modal]; - if (!targetModal) { - closePlayerModal(false); - return; - } - activeModal = modal; - if (modal === "submitIntro") { - submitIntroDraft = { - segmentType: state.submitIntroSegmentType || "intro", - startTime: state.submitIntroStartTime || "00:00", - endTime: state.submitIntroEndTime || "00:00", - status: "", - }; - } - renderActiveModal(); - modalElements.forEach(modalElement => { - setModalVisibility(modalElement, modalElement === targetModal); - }); - renderChrome(); -}; - -const normalizeTracks = tracks => - Array.isArray(tracks) ? tracks.filter(track => track && typeof track === "object") : []; - -const trackIdValue = track => { - const parsed = Number(track && track.id); - return Number.isFinite(parsed) ? parsed : -1; -}; - -const buildCheckIcon = () => { - const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - svg.setAttribute("class", "track-check"); - const use = document.createElementNS("http://www.w3.org/2000/svg", "use"); - use.setAttribute("href", "#icon-check"); - svg.appendChild(use); - return svg; -}; - -const appendTrackRow = (container, label, selected, onSelect, closeAfterSelect = true) => { - const row = document.createElement("button"); - row.type = "button"; - row.className = `track-row${selected ? " selected" : ""}`; - row.addEventListener("click", event => { - event.stopPropagation(); - onSelect(); - if (closeAfterSelect) window.setTimeout(closePlayerModal, 120); - }); - - const text = document.createElement("span"); - text.className = "track-label"; - text.textContent = label; - row.appendChild(text); - row.appendChild(buildCheckIcon()); - container.appendChild(row); -}; - -const appendEmptyTrackState = (container, label) => { - const empty = document.createElement("div"); - empty.className = "track-empty"; - empty.textContent = label; - container.appendChild(empty); -}; - -const renderAudioTrackList = () => { - audioTrackList.textContent = ""; - const tracks = normalizeTracks(state.audioTracks); - if (tracks.length === 0) { - appendEmptyTrackState(audioTrackList, "No audio tracks available"); - return; - } - tracks.forEach(track => { - appendTrackRow( - audioTrackList, - track.label || track.language || `Track ${Number(track.index || 0) + 1}`, - Boolean(track.selected), - () => send("selectAudioTrack", trackIdValue(track)), - ); - }); -}; - -const renderSubtitleTrackList = () => { - subtitleTrackList.textContent = ""; - const tracks = normalizeTracks(state.subtitleTracks); - const hasSelected = tracks.some(track => Boolean(track.selected)); - appendTrackRow( - subtitleTrackList, - state.noneLabel || "None", - !hasSelected, - () => send("selectBuiltInSubtitleTrack", -1), - false, - ); - tracks.forEach(track => { - appendTrackRow( - subtitleTrackList, - track.label || track.language || `Subtitle ${Number(track.index || 0) + 1}`, - Boolean(track.selected), - () => send("selectBuiltInSubtitleTrack", Number(track.index) || 0), - false, - ); - }); -}; - -const renderAddonSubtitleList = () => { - addonSubtitleList.textContent = ""; - if (state.isLoadingAddonSubtitles) { - appendEmptyTrackState(addonSubtitleList, "Loading subtitles..."); - return; - } - const items = normalizeItems(state.addonSubtitleItems); - if (items.length === 0) { - const row = document.createElement("button"); - row.type = "button"; - row.className = "track-row"; - row.addEventListener("click", event => { - event.stopPropagation(); - send("fetchAddonSubtitles", 0); - }); - const text = document.createElement("span"); - text.className = "track-label"; - text.textContent = state.fetchSubtitlesLabel || "Tap to fetch subtitles"; - row.appendChild(text); - addonSubtitleList.appendChild(row); - return; - } - items.forEach(item => { - const label = item.display || item.languageLabel || "Subtitle"; - const secondary = [item.languageLabel, item.addonName].filter(Boolean).join(" • "); - const row = document.createElement("button"); - row.type = "button"; - row.className = `track-row stream-row${item.isSelected ? " selected" : ""}`; - row.addEventListener("click", event => { - event.stopPropagation(); - send("selectAddonSubtitle", Number(item.index) || 0); - }); - const copy = document.createElement("span"); - copy.className = "track-copy"; - const name = document.createElement("span"); - name.className = "stream-name"; - name.textContent = label; - copy.appendChild(name); - if (secondary) { - const detail = document.createElement("span"); - detail.className = "stream-addon"; - detail.textContent = secondary; - copy.appendChild(detail); - } - row.appendChild(copy); - row.appendChild(buildCheckIcon()); - addonSubtitleList.appendChild(row); - }); -}; - -const formatDelay = delayMs => { - const value = Number(delayMs) || 0; - const sign = value >= 0 ? "+" : "-"; - const absolute = Math.abs(value); - const seconds = Math.floor(absolute / 1000); - const millis = absolute % 1000; - return `${sign}${seconds}.${String(millis).padStart(3, "0")}s`; -}; - -const parseArgb = value => { - const raw = String(value || "").replace("#", ""); - const hex = raw.length === 8 ? raw : `FF${raw.padStart(6, "0")}`; - const alpha = parseInt(hex.slice(0, 2), 16); - const red = parseInt(hex.slice(2, 4), 16); - const green = parseInt(hex.slice(4, 6), 16); - const blue = parseInt(hex.slice(6, 8), 16); - return { alpha, red, green, blue, css: `rgba(${red}, ${green}, ${blue}, ${(alpha / 255).toFixed(3)})` }; -}; - -const sameRgb = (left, right) => { - const a = parseArgb(left); - const b = parseArgb(right); - return a.red === b.red && a.green === b.green && a.blue === b.blue; -}; - -const renderSwatches = (container, selectedColor, eventType) => { - container.textContent = ""; - const colors = Array.isArray(state.subtitleColorSwatches) ? state.subtitleColorSwatches : []; - colors.forEach((color, index) => { - const parsed = parseArgb(color); - const swatch = document.createElement("button"); - swatch.type = "button"; - swatch.className = `swatch${parsed.alpha === 0 ? " transparent" : ""}${sameRgb(color, selectedColor) ? " selected" : ""}`; - swatch.style.setProperty("--swatch", parsed.css); - swatch.addEventListener("click", event => { - event.stopPropagation(); - send(eventType, index); - }); - container.appendChild(swatch); - }); -}; - -const renderAutoSyncCues = () => { - autoSyncCueList.textContent = ""; - if (!state.hasSelectedAddonSubtitle) { - autoSyncStatus.textContent = state.selectAddonSubtitleFirstLabel || "Select an addon subtitle first"; - return; - } - if (state.subtitleAutoSyncIsLoading) { - autoSyncStatus.textContent = state.loadingSubtitleLinesLabel || "Loading subtitle lines..."; - } else { - autoSyncStatus.textContent = state.subtitleAutoSyncErrorMessage || ""; - } - normalizeItems(state.subtitleAutoSyncCues).forEach(cue => { - const row = document.createElement("button"); - row.type = "button"; - row.className = "sync-cue"; - row.addEventListener("click", event => { - event.stopPropagation(); - send("subtitleAutoSyncCue", Number(cue.index) || 0); - }); - const time = document.createElement("span"); - time.className = "sync-time"; - time.textContent = cue.timeLabel || ""; - const text = document.createElement("span"); - text.className = "sync-text"; - text.textContent = cue.text || ""; - row.appendChild(time); - row.appendChild(text); - autoSyncCueList.appendChild(row); - }); -}; - -const renderSubtitleStylePanel = () => { - const style = state.subtitleStyle || {}; - subtitleDelayLabel.textContent = state.subtitleDelayLabel || "Subtitle Delay"; - subtitleDelayValue.textContent = formatDelay(state.subtitleDelayMs); - subtitleDelayReset.textContent = state.resetLabel || "Reset"; - autoSyncLabel.textContent = state.autoSyncLabel || "Auto Sync"; - autoSyncReload.textContent = state.reloadSmallLabel || "Reload"; - autoSyncCapture.textContent = state.captureLineLabel || "Capture"; - fontSizeLabel.textContent = state.fontSizeLabel || "Font Size"; - fontSizeValue.textContent = `${Number(style.fontSizeSp) || 18}sp`; - outlineLabel.textContent = state.outlineLabel || "Outline"; - outlineToggle.textContent = style.outlineEnabled ? (state.onLabel || "On") : (state.offLabel || "Off"); - outlineToggle.classList.toggle("primary", Boolean(style.outlineEnabled)); - boldLabel.textContent = state.boldLabel || "Bold"; - boldToggle.textContent = style.bold ? (state.onLabel || "On") : (state.offLabel || "Off"); - boldToggle.classList.toggle("primary", Boolean(style.bold)); - bottomOffsetLabel.textContent = state.bottomOffsetLabel || "Bottom Offset"; - bottomOffsetValue.textContent = String(Number(style.bottomOffset) || 0); - subtitleColorLabel.textContent = state.colorLabel || "Color"; - textOpacityLabel.textContent = state.textOpacityLabel || "Text Opacity"; - const textAlpha = Math.round((parseArgb(style.textColor).alpha / 255) * 100); - textOpacityValue.textContent = `${textAlpha}%`; - outlineColorLabel.textContent = state.outlineColorLabel || "Outline Color"; - subtitleStyleReset.textContent = state.resetDefaultsLabel || "Reset Defaults"; - renderSwatches(subtitleColorSwatches, style.textColor, "subtitleTextColor"); - renderSwatches(outlineColorSwatches, style.outlineColor, "subtitleOutlineColor"); - renderAutoSyncCues(); -}; - -const renderSubtitleModal = () => { - const tab = state.subtitleActiveTab || "BuiltIn"; - subtitlePanelTitle.textContent = state.subtitlesPanelTitle || "Subtitles"; - subtitleBuiltInTab.textContent = state.subtitleBuiltInTabLabel || "Built-in"; - subtitleAddonsTab.textContent = state.subtitleAddonsTabLabel || "Addons"; - subtitleStyleTab.textContent = state.subtitleStyleTabLabel || "Style"; - subtitleBuiltInTab.classList.toggle("selected", tab === "BuiltIn"); - subtitleAddonsTab.classList.toggle("selected", tab === "Addons"); - subtitleStyleTab.classList.toggle("selected", tab === "Style"); - subtitleTrackList.hidden = tab !== "BuiltIn"; - addonSubtitleList.hidden = tab !== "Addons"; - subtitleStylePanel.hidden = tab !== "Style"; - if (tab === "BuiltIn") renderSubtitleTrackList(); - if (tab === "Addons") renderAddonSubtitleList(); - if (tab === "Style") renderSubtitleStylePanel(); -}; - -const normalizeItems = items => - Array.isArray(items) ? items.filter(item => item && typeof item === "object") : []; - -const appendFilterChip = (container, label, selected, onSelect) => { - const chip = document.createElement("button"); - chip.type = "button"; - chip.className = `filter-chip${selected ? " selected" : ""}`; - chip.textContent = label; - chip.addEventListener("click", event => { - event.stopPropagation(); - onSelect(); - }); - container.appendChild(chip); -}; - -const renderFilterRow = (container, filters, selectedId, onSelect) => { - container.textContent = ""; - const list = normalizeItems(filters); - container.hidden = list.length === 0; - list.forEach(filter => { - appendFilterChip( - container, - filter.label || state.allFilterLabel || "All", - String(filter.id || "") === String(selectedId || ""), - () => onSelect(String(filter.id || "")), - ); - }); -}; - -const SourceRowEstimatedHeight = 116; -const SourceRowGap = 10; -const SourceRowOverscanPx = 720; - -const sourceKeyForItems = items => { - const first = items[0] || {}; - const last = items[items.length - 1] || {}; - return [ - sourceFilterId || "", - items.length, - first.index == null ? "" : first.index, - first.label || "", - last.index == null ? "" : last.index, - last.label || "", - ].join("\u0001"); -}; - -const resetSourceVirtualState = (items, key) => { - sourceVirtualKey = key; - sourceVirtualItems = items; - sourceVirtualHeights = new Array(items.length).fill(SourceRowEstimatedHeight); - sourceVirtualOffsets = []; - sourceVirtualTotalHeight = 0; - sourceVirtualSpacer = null; - window.cancelAnimationFrame(sourceVirtualRenderRaf); - sourceVirtualRenderRaf = 0; -}; - -const rebuildSourceVirtualLayout = () => { - let offset = 0; - sourceVirtualOffsets = sourceVirtualItems.map((_, index) => { - const current = offset; - offset += sourceVirtualHeights[index] || SourceRowEstimatedHeight; - if (index < sourceVirtualItems.length - 1) offset += SourceRowGap; - return current; - }); - sourceVirtualTotalHeight = offset; - if (sourceVirtualSpacer) { - sourceVirtualSpacer.style.height = `${sourceVirtualTotalHeight}px`; - } -}; - -const sourceIndexForOffset = offset => { - let low = 0; - let high = sourceVirtualOffsets.length - 1; - let result = 0; - while (low <= high) { - const mid = Math.floor((low + high) / 2); - const rowBottom = sourceVirtualOffsets[mid] + (sourceVirtualHeights[mid] || SourceRowEstimatedHeight); - if (rowBottom < offset) { - low = mid + 1; - } else { - result = mid; - high = mid - 1; - } - } - return result; -}; - -const buildSourceRow = (item, onSelect) => { - const row = document.createElement("button"); - row.type = "button"; - row.className = `track-row stream-row source-row${item.isCurrent ? " selected" : ""}${item.isEnabled === false ? " disabled" : ""}`; - row.disabled = item.isEnabled === false; - row.addEventListener("click", event => { - event.stopPropagation(); - onSelect(item); - }); - - const copy = document.createElement("span"); - copy.className = "track-copy"; - - const top = document.createElement("span"); - top.className = "track-row-top"; - const name = document.createElement("span"); - name.className = "stream-name"; - name.textContent = item.label || "Stream"; - top.appendChild(name); - if (item.isCurrent) { - const chip = document.createElement("span"); - chip.className = "status-chip"; - chip.textContent = state.playingLabel || "Playing"; - top.appendChild(chip); - } - copy.appendChild(top); - - if (item.subtitle) { - const subtitle = document.createElement("span"); - subtitle.className = "stream-subtitle"; - subtitle.textContent = item.subtitle; - copy.appendChild(subtitle); - } - - if (item.addonName) { - const addon = document.createElement("span"); - addon.className = "stream-addon"; - addon.textContent = item.addonName; - copy.appendChild(addon); - } - row.appendChild(copy); - return row; -}; - -const renderSourceVirtualRows = () => { - sourceVirtualRenderRaf = 0; - if (!sourceVirtualSpacer) return; - const count = sourceVirtualItems.length; - if (count === 0) return; - - const viewportTop = Math.max(0, sourceList.scrollTop - SourceRowOverscanPx); - const viewportBottom = sourceList.scrollTop + sourceList.clientHeight + SourceRowOverscanPx; - const start = sourceIndexForOffset(viewportTop); - let end = start; - while (end < count && sourceVirtualOffsets[end] <= viewportBottom) { - end += 1; - } - end = Math.min(count, Math.max(end + 1, start + 1)); - - sourceVirtualSpacer.textContent = ""; - const fragment = document.createDocumentFragment(); - const rendered = []; - for (let index = start; index < end; index += 1) { - const item = sourceVirtualItems[index]; - const wrapper = document.createElement("div"); - wrapper.className = "source-virtual-row"; - wrapper.style.transform = `translateY(${sourceVirtualOffsets[index]}px)`; - const row = buildSourceRow(item, selected => { - send("selectSource", Number(selected.index) || 0); - window.setTimeout(closePlayerModal, 120); - }); - wrapper.appendChild(row); - fragment.appendChild(wrapper); - rendered.push({ index, wrapper }); - } - sourceVirtualSpacer.appendChild(fragment); - - window.requestAnimationFrame(() => { - let changed = false; - rendered.forEach(({ index, wrapper }) => { - const measured = Math.ceil(wrapper.getBoundingClientRect().height); - if (measured > 0 && Math.abs((sourceVirtualHeights[index] || SourceRowEstimatedHeight) - measured) > 1) { - sourceVirtualHeights[index] = measured; - changed = true; - } - }); - if (changed) { - rebuildSourceVirtualLayout(); - requestSourceVirtualRender(); - } - }); -}; - -const requestSourceVirtualRender = () => { - if (sourceVirtualRenderRaf) return; - sourceVirtualRenderRaf = window.requestAnimationFrame(renderSourceVirtualRows); -}; - -const renderSourceModal = () => { - sourcePanelTitle.textContent = state.sourcesPanelTitle || "Sources"; - sourceReloadButton.textContent = state.reloadLabel || "Reload"; - sourceCloseButton.textContent = state.panelCloseLabel || "Close"; - - const filters = normalizeItems(state.sourceFilters); - if (sourceFilterId && !filters.some(filter => String(filter.id || "") === sourceFilterId)) { - sourceFilterId = ""; - } - renderFilterRow(sourceFilterList, filters, sourceFilterId, id => { - sourceFilterId = id; - sourceList.scrollTop = 0; - renderSourceModal(); - }); - - sourceList.textContent = ""; - sourceList.classList.remove("virtualized"); - let items = normalizeItems(state.sourceItems); - if (sourceFilterId) { - items = items.filter(item => String(item.filterId || "") === sourceFilterId); - } - if (items.length === 0) { - sourceVirtualItems = []; - sourceVirtualSpacer = null; - window.cancelAnimationFrame(sourceVirtualRenderRaf); - sourceVirtualRenderRaf = 0; - appendEmptyTrackState( - sourceList, - state.sourceIsLoading ? "Loading streams..." : (state.noStreamsLabel || "No streams found"), - ); - return; - } - const nextKey = sourceKeyForItems(items); - if (nextKey !== sourceVirtualKey) { - resetSourceVirtualState(items, nextKey); - sourceList.scrollTop = 0; - } else { - sourceVirtualItems = items; - } - sourceList.classList.add("virtualized"); - sourceVirtualSpacer = document.createElement("div"); - sourceVirtualSpacer.className = "source-virtual-spacer"; - sourceList.appendChild(sourceVirtualSpacer); - rebuildSourceVirtualLayout(); - renderSourceVirtualRows(); -}; - -const appendEpisodeRow = (container, item) => { - const row = document.createElement("button"); - row.type = "button"; - row.className = `track-row episode-row${item.isCurrent ? " selected" : ""}`; - row.addEventListener("click", event => { - event.stopPropagation(); - send("selectEpisode", Number(item.index) || 0); - }); - - if (item.thumbnail) { - const thumb = document.createElement("span"); - thumb.className = "episode-thumb"; - const image = document.createElement("img"); - image.alt = ""; - image.loading = "lazy"; - setImageSource(image, item.thumbnail); - thumb.appendChild(image); - row.appendChild(thumb); - } - - const copy = document.createElement("span"); - copy.className = "episode-copy"; - const top = document.createElement("span"); - top.className = "episode-row-top"; - if (item.code) { - const code = document.createElement("span"); - code.className = "episode-code"; - code.textContent = item.code; - top.appendChild(code); - } - if (item.isCurrent) { - const chip = document.createElement("span"); - chip.className = "status-chip"; - chip.textContent = state.playingLabel || "Playing"; - top.appendChild(chip); - } - copy.appendChild(top); - const name = document.createElement("span"); - name.className = "episode-name"; - name.textContent = item.title || item.code || "Episode"; - copy.appendChild(name); - if (item.overview) { - const overview = document.createElement("span"); - overview.className = "episode-overview"; - overview.textContent = item.overview; - copy.appendChild(overview); - } - row.appendChild(copy); - container.appendChild(row); -}; - -const ensureEpisodeSeason = () => { - const seasons = normalizeItems(state.episodeSeasons); - if (seasons.length === 0) { - selectedEpisodeSeason = null; - return null; - } - if (!seasons.some(season => Number(season.season) === Number(selectedEpisodeSeason))) { - const preferred = seasons.find(season => Boolean(season.isSelected)) || seasons[0]; - selectedEpisodeSeason = Number(preferred.season) || 0; - } - return selectedEpisodeSeason; -}; - -const renderEpisodeList = () => { - episodesPanelTitle.textContent = state.episodesPanelTitle || "Episodes"; - episodesCloseButton.textContent = state.panelCloseLabel || "Close"; - - const selectedSeason = ensureEpisodeSeason(); - const seasons = normalizeItems(state.episodeSeasons); - renderFilterRow( - seasonFilterList, - seasons.map(season => ({ id: String(season.season), label: season.label })), - selectedSeason == null ? "" : String(selectedSeason), - id => { - selectedEpisodeSeason = Number(id); - renderEpisodeList(); - }, - ); - - episodeList.textContent = ""; - let items = normalizeItems(state.episodeItems); - if (selectedSeason != null) { - items = items.filter(item => Number(item.season) === Number(selectedSeason)); - } - if (items.length === 0) { - appendEmptyTrackState(episodeList, state.noEpisodesLabel || "No episodes available"); - return; - } - items.forEach(item => appendEpisodeRow(episodeList, item)); -}; - -const renderEpisodeStreams = () => { - streamsPanelTitle.textContent = state.selectedEpisodeLabel || state.streamsPanelTitle || "Streams"; - episodeBackButton.textContent = state.backLabel || "Back"; - episodeReloadButton.textContent = state.reloadLabel || "Reload"; - episodeStreamsCloseButton.textContent = state.panelCloseLabel || "Close"; - - const filters = normalizeItems(state.episodeStreamFilters); - if (episodeStreamFilterId && !filters.some(filter => String(filter.id || "") === episodeStreamFilterId)) { - episodeStreamFilterId = ""; - } - renderFilterRow(episodeStreamFilterList, filters, episodeStreamFilterId, id => { - episodeStreamFilterId = id; - renderEpisodeStreams(); - }); - - episodeStreamList.textContent = ""; - let items = normalizeItems(state.episodeStreamItems); - if (episodeStreamFilterId) { - items = items.filter(item => String(item.filterId || "") === episodeStreamFilterId); - } - if (items.length === 0) { - appendEmptyTrackState( - episodeStreamList, - state.episodeStreamsIsLoading ? "Loading streams..." : (state.noStreamsLabel || "No streams found"), - ); - return; - } - items.forEach(item => appendSourceRow(episodeStreamList, item, selected => { - send("selectEpisodeStream", Number(selected.index) || 0); - window.setTimeout(closePlayerModal, 120); - })); -}; - -const renderEpisodesModal = () => { - const showStreams = Boolean(state.episodeStreamsVisible); - episodeListView.hidden = showStreams; - episodeStreamsView.hidden = !showStreams; - if (showStreams) { - renderEpisodeStreams(); - } else { - renderEpisodeList(); - } -}; - -const setInputValue = (input, value) => { - if (document.activeElement !== input && input.value !== value) { - input.value = value; - } -}; - -const renderSubmitIntroModal = () => { - submitIntroPanelTitle.textContent = state.submitIntroPanelTitle || "Submit Timestamps"; - submitIntroCloseButton.textContent = state.panelCloseLabel || "Close"; - segmentTypeLabel.textContent = state.submitIntroSegmentTypeLabel || "SEGMENT TYPE"; - segmentIntroButton.textContent = state.submitIntroSegmentIntroLabel || "Intro"; - segmentRecapButton.textContent = state.submitIntroSegmentRecapLabel || "Recap"; - segmentOutroButton.textContent = state.submitIntroSegmentOutroLabel || "Outro"; - startTimeLabel.textContent = state.submitIntroStartTimeLabel || "START TIME (MM:SS)"; - endTimeLabel.textContent = state.submitIntroEndTimeLabel || "END TIME (MM:SS)"; - captureStartButton.textContent = state.submitIntroCaptureLabel || "Capture"; - captureEndButton.textContent = state.submitIntroCaptureLabel || "Capture"; - submitIntroCancelButton.textContent = state.cancelLabel || "Cancel"; - submitIntroSubmitButton.textContent = state.isSubmitIntroSubmitting - ? `${state.submitIntroSubmitLabel || "Submit"}...` - : (state.submitIntroSubmitLabel || "Submit"); - submitIntroSubmitButton.disabled = Boolean(state.isSubmitIntroSubmitting); - - [segmentIntroButton, segmentRecapButton, segmentOutroButton].forEach(button => { - button.classList.toggle("selected", button.dataset.segment === submitIntroDraft.segmentType); - }); - setInputValue(submitIntroStartInput, submitIntroDraft.startTime); - setInputValue(submitIntroEndInput, submitIntroDraft.endTime); - submitIntroStatus.textContent = submitIntroDraft.status || state.submitIntroStatusMessage || ""; -}; - -const renderP2pConsentModal = () => { - p2pConsentTitle.textContent = state.p2pConsentTitle || "P2P Streaming"; - p2pConsentBody.textContent = state.p2pConsentBody || ""; - p2pConsentCloseButton.textContent = state.p2pConsentCancelLabel || "Cancel"; - p2pConsentCancelButton.textContent = state.p2pConsentCancelLabel || "Cancel"; - p2pConsentEnableButton.textContent = state.p2pConsentEnableLabel || "Enable P2P"; -}; - -const renderActiveModal = () => { - if (activeModal === "audio") renderAudioTrackList(); - if (activeModal === "subtitles") renderSubtitleModal(); - if (activeModal === "sources") renderSourceModal(); - if (activeModal === "episodes") renderEpisodesModal(); - if (activeModal === "submitIntro") renderSubmitIntroModal(); - if (activeModal === "p2pConsent") renderP2pConsentModal(); -}; - -window.nuvioNativeViewportChanged = () => { - root.classList.add("native-resizing"); - window.clearTimeout(nativeViewportTimer); - nativeViewportTimer = window.setTimeout(() => { - root.classList.remove("native-resizing"); - }, 180); - if (activeModal) renderActiveModal(); -}; - -const trackListSignature = tracks => - normalizeTracks(tracks) - .map(track => [ - track.id == null ? "" : String(track.id), - track.index == null ? "" : String(track.index), - track.label == null ? "" : String(track.label), - track.language == null ? "" : String(track.language), - Boolean(track.selected) ? "1" : "0", - ].join(":")) - .join("|"); - -const renderOpeningOverlay = suppress => { - const progress = normalizedOpeningProgress(); - const artworkUrl = setImageSource(openingArtwork, state.openingArtwork); - const logoUrl = setImageSource(openingLogoBase, state.openingLogo); - setImageSource(openingLogoFill, state.openingLogo); - - const hasProgress = progress !== null; - const openingBootstrap = !hasReceivedPlayerControls; - const wantsOpening = Boolean(openingBootstrap || state.showOpeningOverlay); - const showOpening = Boolean(!suppress && wantsOpening && state.isLoading); - const titleText = String(state.openingTitle || state.title || "").trim(); - const messageText = String(state.openingMessage || "").trim(); - const showHorizontalProgress = hasProgress && !logoUrl; - - root.classList.toggle("opening-active", showOpening); - openingOverlay.classList.toggle("visible", showOpening); - openingOverlay.classList.toggle("has-artwork", Boolean(artworkUrl)); - openingOverlay.classList.toggle("has-progress", hasProgress); - openingOverlay.setAttribute("aria-hidden", showOpening ? "false" : "true"); - openingBackButton.setAttribute("aria-label", state.closeLabel || "Close player"); - - openingLogoSlot.hidden = !logoUrl; - openingLogoFillClip.style.width = `${(progress || 0) * 100}%`; - - openingTitle.textContent = titleText; - openingTitle.hidden = Boolean(logoUrl || !titleText); - openingSpinner.hidden = Boolean(logoUrl || titleText); - - openingMessage.textContent = messageText; - openingStatus.hidden = !(messageText || showHorizontalProgress); - openingProgressTrack.hidden = !showHorizontalProgress; - openingProgressBar.style.width = `${(progress || 0) * 100}%`; - - return showOpening; -}; - -const renderPlaybackError = () => { - const messageText = playbackErrorText(); - const showError = Boolean(messageText); - const titleText = String(state.playbackErrorTitle || "Playback error").trim(); - const actionText = String(state.playbackErrorActionLabel || "Go back").trim(); - - root.classList.toggle("error-active", showError); - playbackError.classList.toggle("visible", showError); - playbackError.setAttribute("aria-hidden", showError ? "false" : "true"); - playbackError.setAttribute("aria-label", titleText || "Playback error"); - playbackErrorTitle.textContent = titleText || "Playback error"; - playbackErrorMessage.textContent = messageText; - playbackErrorActionLabel.textContent = actionText || "Go back"; - playbackErrorAction.setAttribute("aria-label", actionText || "Go back"); - - return showError; -}; - -const resetSkipPromptAutoHide = () => { - window.clearTimeout(skipPromptAutoHideTimer); - skipPromptAutoHideTimer = 0; - skipPromptAutoHideActive = false; - skipPromptAutoHidden = false; - skipPromptProgress.style.transition = "none"; - skipPromptProgress.style.width = "0%"; -}; - -const startSkipPromptAutoHide = () => { - if (skipPromptAutoHideActive || skipPromptAutoHidden) return; - skipPromptAutoHideActive = true; - skipPromptProgress.style.transition = "none"; - skipPromptProgress.style.width = "0%"; - window.requestAnimationFrame(() => { - window.requestAnimationFrame(() => { - skipPromptProgress.style.transition = `width ${prefersReducedMotion ? 1 : 10000}ms linear`; - skipPromptProgress.style.width = "100%"; - }); - }); - skipPromptAutoHideTimer = window.setTimeout(() => { - skipPromptAutoHideActive = false; - skipPromptAutoHidden = true; - renderNativePlaybackPrompts(); - }, prefersReducedMotion ? 1 : 10000); -}; - -const renderNativePlaybackPrompts = () => { - const nextSkipKey = [ - state.skipPromptStartMs || 0, - state.skipPromptEndMs || 0, - state.skipPromptLabel || "", - ].join(":"); - if ( - nextSkipKey !== skipPromptKey || - !state.skipPromptVisible || - (skipPromptWasDismissed && !state.skipPromptDismissed) - ) { - skipPromptKey = nextSkipKey; - resetSkipPromptAutoHide(); - } - skipPromptWasDismissed = Boolean(state.skipPromptDismissed); - - const shouldShowSkip = Boolean(state.skipPromptVisible && (!state.skipPromptDismissed || state.controlsVisible)); - const showSkip = Boolean(shouldShowSkip && (!skipPromptAutoHidden || state.controlsVisible)); - const showSkipProgress = Boolean(showSkip && !state.controlsVisible && !skipPromptAutoHidden && !state.skipPromptDismissed); - skipPromptLabel.textContent = state.skipPromptLabel || "Skip"; - skipPrompt.setAttribute("aria-label", state.skipPromptLabel || "Skip"); - skipPrompt.setAttribute("aria-hidden", showSkip ? "false" : "true"); - skipPrompt.classList.toggle("visible", showSkip); - skipPrompt.classList.toggle("show-progress", showSkipProgress); - if (showSkipProgress) { - startSkipPromptAutoHide(); - } else if (!showSkip || state.controlsVisible || state.skipPromptDismissed) { - window.clearTimeout(skipPromptAutoHideTimer); - skipPromptAutoHideTimer = 0; - skipPromptAutoHideActive = false; - } - - const showNextEpisode = Boolean(state.nextEpisodeVisible); - const nextThumbUrl = setImageSource(nextEpisodeThumb, state.nextEpisodeThumbnail); - nextEpisodeHeader.textContent = state.nextEpisodeHeaderLabel || "Next episode"; - nextEpisodeTitle.textContent = state.nextEpisodeTitle || ""; - nextEpisodeStatus.textContent = state.nextEpisodeStatus || ""; - nextEpisodeStatus.hidden = !state.nextEpisodeStatus; - nextEpisodeAction.textContent = state.nextEpisodeActionLabel || "Play"; - nextEpisodeCard.setAttribute("aria-hidden", showNextEpisode ? "false" : "true"); - nextEpisodeCard.classList.toggle("visible", showNextEpisode); - nextEpisodeCard.classList.toggle("playable", Boolean(state.nextEpisodePlayable)); - nextEpisodeCard.classList.toggle("has-thumb", Boolean(nextThumbUrl)); -}; - -const isOpeningOverlayActive = () => - Boolean((!hasReceivedPlayerControls || state.showOpeningOverlay) && state.isLoading); - -const isChromeInteractionTarget = target => - Boolean(target && target.closest && target.closest(chromeInteractionSelector)); - -const isInteractingWithChrome = () => - Boolean(isChromePointerInside || isChromePointerDown || isChromeFocusInside); - -const canAutoHideChrome = showOpening => Boolean( - state.controlsVisible && - state.isPlaying && - !state.isLoading && - !state.isLocked && - !activeModal && - !isScrubbing && - !isInteractingWithChrome() && - !playbackErrorText() && - !showOpening, -); - -const currentChromeAutoHideKey = showOpening => { - if (!canAutoHideChrome(showOpening)) return ""; - return [ - chromeAutoHideActivity, - state.controlsVisible ? "visible" : "hidden", - state.isPlaying ? "playing" : "paused", - state.isLoading ? "loading" : "ready", - state.isLocked ? "locked" : "unlocked", - activeModal || "none", - isScrubbing ? "scrubbing" : "idle", - isInteractingWithChrome() ? "interacting" : "idle-controls", - showOpening ? "opening" : "ready", - ].join(":"); -}; - -const clearChromeAutoHideTimer = () => { - window.clearTimeout(chromeAutoHideTimer); - chromeAutoHideTimer = 0; - chromeAutoHideKey = ""; -}; - -const hideChromeFromAutoTimer = () => { - if (!canAutoHideChrome(isOpeningOverlayActive())) return; - state = { ...state, controlsVisible: false }; - renderChrome(); - send("hideChrome", 0); -}; - -const syncChromeAutoHideTimer = showOpening => { - const key = currentChromeAutoHideKey(showOpening); - if (!key) { - clearChromeAutoHideTimer(); - return; - } - if (chromeAutoHideKey === key) return; - - window.clearTimeout(chromeAutoHideTimer); - chromeAutoHideKey = key; - chromeAutoHideTimer = window.setTimeout(() => { - chromeAutoHideTimer = 0; - if (currentChromeAutoHideKey(isOpeningOverlayActive()) !== key) return; - chromeAutoHideKey = ""; - hideChromeFromAutoTimer(); - }, chromeAutoHideDelayMs); -}; - -const noteChromeActivity = (force = false) => { - if (!state.controlsVisible || state.isLocked) return; - const now = window.performance ? window.performance.now() : Date.now(); - if (!force && now - chromeInteractionLastNotedAt < chromeActivityThrottleMs) { - syncChromeAutoHideTimer(isOpeningOverlayActive()); - return; - } - chromeInteractionLastNotedAt = now; - chromeAutoHideActivity += 1; - syncChromeAutoHideTimer(isOpeningOverlayActive()); -}; - -const updateChromePointerInside = inside => { - if (isChromePointerInside === inside) return; - isChromePointerInside = inside; - noteChromeActivity(true); -}; - -const finishChromePointerInteraction = event => { - isChromePointerDown = false; - if (event && event.type !== "pointercancel") { - isChromePointerInside = isChromeInteractionTarget(event.target); - } else { - isChromePointerInside = false; - } - clearPressedButton(); - noteChromeActivity(true); -}; - -const renderChrome = () => { - const durationMs = Math.max(0, Number(state.durationMs) || 0); - const positionMs = isScrubbing ? scrubPositionMs : Math.max(0, Number(state.positionMs) || 0); - const isPlaying = Boolean(state.isPlaying); - const showError = renderPlaybackError(); - root.classList.toggle("locked", Boolean(state.isLocked)); - root.classList.toggle("locked-visible", Boolean(state.isLocked && state.lockedOverlayVisible)); - root.classList.toggle("chrome-hidden", Boolean(showError || (!state.controlsVisible && !(state.isLocked && state.lockedOverlayVisible)))); - root.classList.toggle("source-visible", Boolean(!showError && !isPlaying && !state.isLoading && (state.streamTitle || state.providerName))); - const showOpening = renderOpeningOverlay(showError); - renderPauseMetadataOverlay(showOpening || showError); - syncParentalGuide(showOpening || showError); - - title.textContent = state.title || ""; - setText(episode, state.episodeText); - setText(streamTitle, state.streamTitle); - setText(providerName, state.providerName); - resizeLabel.textContent = state.resizeModeLabel || "Fit"; - speedLabel.textContent = state.playbackSpeedLabel || "1x"; - subtitlesLabel.textContent = state.subtitlesLabel || "Subs"; - audioLabel.textContent = state.audioLabel || "Audio"; - sourcesLabel.textContent = state.sourcesLabel || "Sources"; - episodesLabel.textContent = state.episodesLabel || "Episodes"; - lockedLabel.textContent = state.tapToUnlockLabel || "Tap to unlock"; - const showBuffering = Boolean(!showError && state.isLoading && !state.isLocked && !activeModal && !showOpening); - bufferingStatus.classList.toggle("visible", showBuffering); - bufferingStatus.setAttribute("aria-hidden", showBuffering ? "false" : "true"); - - setVisible(submitIntroButton, Boolean(state.showSubmitIntro)); - setVisible(videoSettingsButton, Boolean(state.showVideoSettings)); - setVisible(sourcesButton, Boolean(state.showSources)); - setVisible(episodesButton, Boolean(state.showEpisodes)); - - const playPauseLabel = isPlaying ? state.pauseLabel : state.playLabel; - if (toggle) { - toggle.setAttribute("aria-label", playPauseLabel || (isPlaying ? "Pause" : "Play")); - } - if (toggleIcon) { - toggleIcon.setAttribute("href", isPlaying ? "#icon-pause" : "#icon-play"); - } - lockButton.setAttribute("aria-label", state.isLocked ? state.unlockLabel : state.lockLabel); - lockIcon.setAttribute("href", state.isLocked ? "#icon-lock-open" : "#icon-lock"); - backButton.setAttribute("aria-label", state.closeLabel || "Close player"); - submitIntroButton.setAttribute("aria-label", state.submitIntroLabel || "Submit Intro"); - videoSettingsButton.setAttribute("aria-label", state.videoSettingsLabel || "Video settings"); - seek.disabled = Boolean(state.isLocked); - setProgress(positionMs, durationMs); - if (showError) { - skipPrompt.classList.remove("visible", "show-progress"); - skipPrompt.setAttribute("aria-hidden", "true"); - nextEpisodeCard.classList.remove("visible"); - nextEpisodeCard.setAttribute("aria-hidden", "true"); - } else { - renderNativePlaybackPrompts(); - } - syncChromeAutoHideTimer(showOpening); -}; - -const render = () => { - applyTheme(); - renderChrome(); - renderActiveModal(); -}; - -const focusShortcutRoot = () => { - if (document.activeElement !== root) { - root.focus({ preventScroll: true }); - } -}; - -const isTextEntryTarget = target => { - const element = target && target.closest && target.closest("input, textarea, select, [contenteditable='true']"); - return Boolean(element); -}; - -const shortcutCommandForEvent = event => { - if (event.metaKey || event.ctrlKey || event.altKey) return ""; - switch (event.code) { - case "Space": - case "KeyK": - return "keyboardToggle"; - case "ArrowLeft": - case "KeyJ": - return "keyboardSeekBack"; - case "ArrowRight": - case "KeyL": - return "keyboardSeekForward"; - default: - return ""; - } -}; - -const toggleChrome = () => { - if (playbackErrorText()) return; - if (state.isLocked) { - send("revealLockedOverlay", 0); - return; - } - const nextControlsVisible = !state.controlsVisible; - if (nextControlsVisible) { - chromeAutoHideActivity += 1; - } else { - clearChromeAutoHideTimer(); - } - state = { ...state, controlsVisible: nextControlsVisible }; - renderChrome(); - send("toggleChrome", 0); -}; - -const clearPressedButton = () => { - if (!pressedButton) return; - pressedButton.classList.remove("is-pressed"); - pressedButton = null; -}; - -document.addEventListener("pointerdown", event => { - const interactingWithChrome = isChromeInteractionTarget(event.target); - if (interactingWithChrome) { - isChromePointerDown = true; - isChromePointerInside = true; - noteChromeActivity(true); - } - if (!isTextEntryTarget(event.target)) { - focusShortcutRoot(); - if (!interactingWithChrome) { - noteChromeActivity(true); - } - } - const button = event.target.closest("button"); - if (!button || button.disabled) return; - clearPressedButton(); - pressedButton = button; - button.classList.add("is-pressed"); -}, true); - -document.addEventListener("pointermove", event => { - const inside = isChromeInteractionTarget(event.target); - updateChromePointerInside(inside); - if (inside) { - noteChromeActivity(); - } -}, true); - -document.addEventListener("pointerup", finishChromePointerInteraction, true); -document.addEventListener("pointercancel", finishChromePointerInteraction, true); -document.addEventListener("dragend", clearPressedButton, true); -document.addEventListener("pointerleave", () => { - updateChromePointerInside(false); -}, true); -document.addEventListener("focusin", event => { - isChromeFocusInside = isChromeInteractionTarget(event.target); - if (isChromeFocusInside) { - noteChromeActivity(true); - } -}, true); -document.addEventListener("focusout", () => { - window.setTimeout(() => { - isChromeFocusInside = isChromeInteractionTarget(document.activeElement); - noteChromeActivity(true); - }, 0); -}, true); -window.addEventListener("blur", () => { - isChromePointerInside = false; - isChromePointerDown = false; - isChromeFocusInside = false; - clearPressedButton(); - syncChromeAutoHideTimer(isOpeningOverlayActive()); -}); - -document.querySelectorAll("[data-command]").forEach(button => { - button.addEventListener("click", event => { - event.stopPropagation(); - noteChromeActivity(true); - const command = button.dataset.command; - if (command === "audio") { - openPlayerModal("audio"); - return; - } - if (command === "subtitles") { - openPlayerModal("subtitles"); - return; - } - if (command === "sources") { - sourceFilterId = ""; - openPlayerModal("sources"); - send("sources", 0); - return; - } - if (command === "episodes") { - episodeStreamFilterId = ""; - openPlayerModal("episodes"); - send("episodes", 0); - return; - } - if (command === "submitIntro") { - openPlayerModal("submitIntro"); - return; - } - send(command, 0); - }); -}); - -openingOverlay.addEventListener("click", event => { - event.stopPropagation(); - if (!event.target.closest("button,input")) { - toggleChrome(); - } -}); - -modalElements.forEach(modal => { - modal.addEventListener("click", event => { - event.stopPropagation(); - if (event.target === modal) closePlayerModal(true); - }); -}); - -const selectSubtitleTab = (tab, index) => { - state = { ...state, subtitleActiveTab: tab }; - renderSubtitleModal(); - send("subtitleTab", index); -}; - -subtitleBuiltInTab.addEventListener("click", event => { - event.stopPropagation(); - selectSubtitleTab("BuiltIn", 0); -}); -subtitleAddonsTab.addEventListener("click", event => { - event.stopPropagation(); - selectSubtitleTab("Addons", 1); -}); -subtitleStyleTab.addEventListener("click", event => { - event.stopPropagation(); - selectSubtitleTab("Style", 2); -}); -subtitleDelayMinus.addEventListener("click", event => { - event.stopPropagation(); - send("subtitleDelayDelta", -100); -}); -subtitleDelayPlus.addEventListener("click", event => { - event.stopPropagation(); - send("subtitleDelayDelta", 100); -}); -subtitleDelayReset.addEventListener("click", event => { - event.stopPropagation(); - send("subtitleDelayReset", 0); -}); -autoSyncReload.addEventListener("click", event => { - event.stopPropagation(); - send("subtitleAutoSyncReload", 0); -}); -autoSyncCapture.addEventListener("click", event => { - event.stopPropagation(); - send("subtitleAutoSyncCapture", 0); -}); -fontSizeMinus.addEventListener("click", event => { - event.stopPropagation(); - send("subtitleFontSizeDelta", -2); -}); -fontSizePlus.addEventListener("click", event => { - event.stopPropagation(); - send("subtitleFontSizeDelta", 2); -}); -outlineToggle.addEventListener("click", event => { - event.stopPropagation(); - send("subtitleOutlineToggle", 0); -}); -boldToggle.addEventListener("click", event => { - event.stopPropagation(); - send("subtitleBoldToggle", 0); -}); -bottomOffsetMinus.addEventListener("click", event => { - event.stopPropagation(); - send("subtitleBottomOffsetDelta", -5); -}); -bottomOffsetPlus.addEventListener("click", event => { - event.stopPropagation(); - send("subtitleBottomOffsetDelta", 5); -}); -textOpacityMinus.addEventListener("click", event => { - event.stopPropagation(); - const style = state.subtitleStyle || {}; - const next = Math.max(0, Math.round((parseArgb(style.textColor).alpha / 255) * 100) - 10); - send("subtitleTextOpacity", next); -}); -textOpacityPlus.addEventListener("click", event => { - event.stopPropagation(); - const style = state.subtitleStyle || {}; - const next = Math.min(100, Math.round((parseArgb(style.textColor).alpha / 255) * 100) + 10); - send("subtitleTextOpacity", next); -}); -subtitleStyleReset.addEventListener("click", event => { - event.stopPropagation(); - send("subtitleStyleReset", 0); -}); - -sourceReloadButton.addEventListener("click", event => { - event.stopPropagation(); - send("reloadSources", 0); -}); -sourceCloseButton.addEventListener("click", event => { - event.stopPropagation(); - closePlayerModal(); -}); -sourceList.addEventListener("scroll", () => { - if (activeModal === "sources") { - requestSourceVirtualRender(); - } -}, { passive: true }); -episodesCloseButton.addEventListener("click", event => { - event.stopPropagation(); - closePlayerModal(); -}); -episodeStreamsCloseButton.addEventListener("click", event => { - event.stopPropagation(); - closePlayerModal(); -}); -episodeBackButton.addEventListener("click", event => { - event.stopPropagation(); - episodeStreamFilterId = ""; - send("backToEpisodes", 0); -}); -episodeReloadButton.addEventListener("click", event => { - event.stopPropagation(); - send("reloadEpisodeStreams", 0); -}); - -const updateSubmitSegment = segment => { - submitIntroDraft.segmentType = segment; - submitIntroDraft.status = ""; - renderSubmitIntroModal(); -}; - -[segmentIntroButton, segmentRecapButton, segmentOutroButton].forEach(button => { - button.addEventListener("click", event => { - event.stopPropagation(); - updateSubmitSegment(button.dataset.segment || "intro"); - }); -}); - -const currentTimeText = () => formatTime(isScrubbing ? scrubPositionMs : state.positionMs); - -captureStartButton.addEventListener("click", event => { - event.stopPropagation(); - submitIntroDraft.startTime = currentTimeText(); - submitIntroDraft.status = ""; - renderSubmitIntroModal(); -}); -captureEndButton.addEventListener("click", event => { - event.stopPropagation(); - submitIntroDraft.endTime = currentTimeText(); - submitIntroDraft.status = ""; - renderSubmitIntroModal(); -}); - -submitIntroCloseButton.addEventListener("click", event => { - event.stopPropagation(); - closePlayerModal(); -}); -submitIntroCancelButton.addEventListener("click", event => { - event.stopPropagation(); - closePlayerModal(); -}); - -const parseIntroTime = raw => { - const value = String(raw || "").trim(); - if (!value) return null; - const separator = value.includes(":") ? ":" : (value.includes(".") ? "." : ""); - if (separator) { - const parts = value.split(separator); - if (parts.length !== 2) return null; - const minutes = Number(parts[0]); - const seconds = Number(parts[1]); - if (!Number.isFinite(minutes) || !Number.isFinite(seconds) || seconds < 0 || seconds >= 60) return null; - return minutes * 60 + seconds; - } - const seconds = Number(value); - return Number.isFinite(seconds) && seconds >= 0 ? seconds : null; -}; - -submitIntroSubmitButton.addEventListener("click", event => { - event.stopPropagation(); - submitIntroDraft.startTime = submitIntroStartInput.value; - submitIntroDraft.endTime = submitIntroEndInput.value; - const start = parseIntroTime(submitIntroDraft.startTime); - const end = parseIntroTime(submitIntroDraft.endTime); - if (start == null || end == null || end <= start) { - submitIntroDraft.status = "Check the start and end times."; - renderSubmitIntroModal(); - return; - } - const segmentIndex = submitIntroDraft.segmentType === "recap" ? 1 : (submitIntroDraft.segmentType === "outro" ? 2 : 0); - submitIntroDraft.status = ""; - send("submitIntroSegment", segmentIndex); - send("submitIntroStart", start); - send("submitIntroEnd", end); - send("submitIntroCommit", 0); -}); - -const cancelP2pConsent = () => { - send("cancelP2pForPlayerControls", 0); - closePlayerModal(); -}; - -p2pConsentCloseButton.addEventListener("click", event => { - event.stopPropagation(); - cancelP2pConsent(); -}); -p2pConsentCancelButton.addEventListener("click", event => { - event.stopPropagation(); - cancelP2pConsent(); -}); -p2pConsentEnableButton.addEventListener("click", event => { - event.stopPropagation(); - send("enableP2pForPlayerControls", 0); -}); - -skipPrompt.addEventListener("click", event => { - event.stopPropagation(); - send("skipInterval", 0); -}); - -nextEpisodeCard.addEventListener("click", event => { - event.stopPropagation(); - if (state.nextEpisodePlayable) { - send("playNextEpisode", 0); - } -}); - -seek.addEventListener("input", () => { - noteChromeActivity(); - isScrubbing = true; - scrubPositionMs = rangePositionMs(); - setProgress(scrubPositionMs, state.durationMs); - send("scrubChange", scrubPositionMs); -}); - -seek.addEventListener("change", () => { - noteChromeActivity(); - scrubPositionMs = rangePositionMs(); - isScrubbing = false; - send("scrubFinish", scrubPositionMs); - state.positionMs = scrubPositionMs; - render(); -}); - -window.playerUpdate = update => { - const durationMs = Math.round((Number(update.duration) || 0) * 1000); - const positionMs = Math.round((Number(update.position) || 0) * 1000); - const audioTracks = normalizeTracks(update.audioTracks); - const subtitleTracks = normalizeTracks(update.subtitleTracks); - const audioTracksChanged = trackListSignature(audioTracks) !== trackListSignature(state.audioTracks); - const subtitleTracksChanged = trackListSignature(subtitleTracks) !== trackListSignature(state.subtitleTracks); - state = { - ...state, - durationMs, - positionMs, - isPlaying: !Boolean(update.paused), - isLoading: Boolean(update.loading || update.isLoading), - audioTracks, - subtitleTracks, - }; - renderChrome(); - if ((audioTracksChanged && activeModal === "audio") || - (subtitleTracksChanged && activeModal === "subtitles")) { - renderActiveModal(); - } -}; - -window.playerControls = nextState => { - const previousCloseToken = Number(state.closeModalsToken) || 0; - state = { ...state, ...nextState }; - hasReceivedPlayerControls = true; - const closeToken = Number(state.closeModalsToken) || 0; - if (closeToken !== previousCloseToken) { - closePlayerModal(); - } - if (state.showP2pConsent && activeModal !== "p2pConsent") { - openPlayerModal("p2pConsent"); - } else if (!state.showP2pConsent && activeModal === "p2pConsent") { - closePlayerModal(); - } - render(); -}; - -root.addEventListener("click", event => { - if (playbackErrorText()) return; - if (event.target.closest("button,input")) return; - window.clearTimeout(tapTimer); - tapTimer = window.setTimeout(() => { - toggleChrome(); - }, 220); -}); - -root.addEventListener("dblclick", event => { - if (playbackErrorText()) return; - if (event.target.closest("button,input")) return; - event.preventDefault(); - window.clearTimeout(tapTimer); - send("toggleFullscreen", 0); -}); - -document.addEventListener("keydown", event => { - if (event.key === "Escape" && playbackErrorText()) { - event.preventDefault(); - send("back", 0); - return; - } - if (event.key === "Escape" && activeModal) { - event.preventDefault(); - closePlayerModal(true); - focusShortcutRoot(); - return; - } - if (event.key === "Escape") { - event.preventDefault(); - send("back", 0); - return; - } - if (playbackErrorText()) return; - const isMacFullscreenShortcut = event.code === "KeyF" && event.metaKey && event.ctrlKey && !event.altKey; - if (event.code === "F11" || isMacFullscreenShortcut) { - event.preventDefault(); - focusShortcutRoot(); - send("toggleFullscreen", 0); - return; - } - if (activeModal || isTextEntryTarget(event.target)) { - return; - } - const command = shortcutCommandForEvent(event); - if (!command) { - return; - } - event.preventDefault(); - focusShortcutRoot(); - noteChromeActivity(); - send(command, 0); -}); - -setProgress(0, 0); -focusShortcutRoot(); -render(); -send("controlsReady", 0); diff --git a/composeApp/src/desktopTest/kotlin/com/nuvio/app/features/plugins/PluginRuntimeDesktopTest.kt b/composeApp/src/desktopTest/kotlin/com/nuvio/app/features/plugins/PluginRuntimeDesktopTest.kt deleted file mode 100644 index ecf9739c..00000000 --- a/composeApp/src/desktopTest/kotlin/com/nuvio/app/features/plugins/PluginRuntimeDesktopTest.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.nuvio.app.features.plugins - -import kotlinx.coroutines.runBlocking -import kotlin.test.Test -import kotlin.test.assertEquals - -class PluginRuntimeDesktopTest { - @Test - fun `desktop runtime executes scraper code`() = runBlocking { - val results = PluginRuntime.executePlugin( - code = """ - module.exports.getStreams = async function(tmdbId, mediaType) { - return [{ - title: "Desktop stream " + tmdbId + " " + mediaType, - url: "https://example.test/movie.mp4", - quality: "1080p", - provider: "Desktop Test" - }]; - }; - """.trimIndent(), - tmdbId = "603", - mediaType = "movie", - season = null, - episode = null, - scraperId = "desktop-runtime-test", - ) - - assertEquals(1, results.size) - assertEquals("Desktop stream 603 movie", results.single().title) - assertEquals("https://example.test/movie.mp4", results.single().url) - assertEquals("1080p", results.single().quality) - assertEquals("Desktop Test", results.single().provider) - } -} diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt index da5a0e8f..88439c51 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt @@ -413,11 +413,11 @@ actual object PluginRepository { } private fun PluginManifestScraper.isSupportedOnCurrentPlatform(): Boolean { - val platformTags = currentPluginPlatformTags().map { it.lowercase() }.toSet() + val platform = currentPluginPlatform().lowercase() val supported = supportedPlatforms?.map { it.lowercase() }?.toSet().orEmpty() val disabled = disabledPlatforms?.map { it.lowercase() }?.toSet().orEmpty() - if (supported.isNotEmpty() && platformTags.none { it in supported }) return false - if (platformTags.any { it in disabled }) return false + if (supported.isNotEmpty() && platform !in supported) return false + if (platform in disabled) return false return true } diff --git a/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt b/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt index 70588602..5f7b3114 100644 --- a/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt +++ b/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt @@ -2,8 +2,6 @@ package com.nuvio.app.core.build actual object AppFeaturePolicy { actual val pluginsEnabled: Boolean = false - actual val downloadsEnabled: Boolean = true - actual val notificationsEnabled: Boolean = true actual val p2pEnabled: Boolean = false actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.EXTERNAL actual val heroTrailerPlaybackSupported: Boolean = false diff --git a/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppVersionPolicy.ios.kt b/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppVersionPolicy.ios.kt deleted file mode 100644 index 1d181ca6..00000000 --- a/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppVersionPolicy.ios.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.nuvio.app.core.build - -actual object AppVersionPolicy { - actual val displayVersionName: String = AppVersionConfig.VERSION_NAME - actual val displayVersionCode: Int = AppVersionConfig.VERSION_CODE - actual val basedOnVersionName: String? = null -} diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt index 6f46e9e9..670f8092 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt @@ -2,8 +2,6 @@ package com.nuvio.app.core.build actual object AppFeaturePolicy { actual val pluginsEnabled: Boolean = true - actual val downloadsEnabled: Boolean = true - actual val notificationsEnabled: Boolean = true actual val p2pEnabled: Boolean = false actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.IN_APP actual val heroTrailerPlaybackSupported: Boolean = false diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppVersionPolicy.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppVersionPolicy.ios.kt deleted file mode 100644 index 1d181ca6..00000000 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppVersionPolicy.ios.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.nuvio.app.core.build - -actual object AppVersionPolicy { - actual val displayVersionName: String = AppVersionConfig.VERSION_NAME - actual val displayVersionCode: Int = AppVersionConfig.VERSION_CODE - actual val basedOnVersionName: String? = null -} diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.ios.kt index 0d304e96..a30ad428 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.ios.kt @@ -19,7 +19,5 @@ internal object PluginStorage { internal fun currentPluginPlatform(): String = "ios" -internal fun currentPluginPlatformTags(): Set = setOf(currentPluginPlatform()) - internal fun currentEpochMillis(): Long = (platform.Foundation.NSDate().timeIntervalSince1970 * 1000.0).toLong() diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/Platform.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/Platform.ios.kt index 4f79e7c8..ee348bc5 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/Platform.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/Platform.ios.kt @@ -8,5 +8,4 @@ class IOSPlatform: Platform { actual fun getPlatform(): Platform = IOSPlatform() -internal actual val isIos: Boolean = true -internal actual val isDesktop: Boolean = false +internal actual val isIos: Boolean = true \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.ios.kt deleted file mode 100644 index fa48a20c..00000000 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.ios.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.nuvio.app.core.ui - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.FilterQuality -import androidx.compose.ui.graphics.drawscope.DrawScope.Companion.DefaultFilterQuality -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.layout.ContentScale -import coil3.compose.AsyncImage -import coil3.compose.AsyncImagePainter - -@Composable -internal actual fun NuvioAsyncImage( - model: Any?, - contentDescription: String?, - modifier: Modifier, - placeholder: Painter?, - error: Painter?, - fallback: Painter?, - onLoading: ((AsyncImagePainter.State.Loading) -> Unit)?, - onSuccess: ((AsyncImagePainter.State.Success) -> Unit)?, - onError: ((AsyncImagePainter.State.Error) -> Unit)?, - alignment: Alignment, - contentScale: ContentScale, - alpha: Float, - colorFilter: ColorFilter?, - filterQuality: FilterQuality?, - clipToBounds: Boolean, - desktopImageScaling: NuvioDesktopImageScaling, -) { - AsyncImage( - model = model, - contentDescription = contentDescription, - modifier = modifier, - placeholder = placeholder, - error = error, - fallback = fallback, - onLoading = onLoading, - onSuccess = onSuccess, - onError = onError, - alignment = alignment, - contentScale = contentScale, - alpha = alpha, - colorFilter = colorFilter, - filterQuality = filterQuality ?: DefaultFilterQuality, - clipToBounds = clipToBounds, - ) -} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/SecondaryClick.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/SecondaryClick.ios.kt deleted file mode 100644 index a01bf27a..00000000 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/SecondaryClick.ios.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.nuvio.app.core.ui - -import androidx.compose.ui.Modifier - -internal actual fun Modifier.secondaryClick(onClick: (() -> Unit)?): Modifier = this diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt index a32f0d36..adfa0126 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt @@ -33,13 +33,7 @@ actual fun PlatformPlayerSurface( modifier: Modifier, playWhenReady: Boolean, resizeMode: PlayerResizeMode, - initialPositionMs: Long, useNativeController: Boolean, - playerControlsState: PlayerControlsState, - onPlayerControlsAction: (PlayerControlsAction) -> Boolean, - onPlayerControlsEvent: (String, Double) -> Boolean, - onPlayerControlsScrubChange: (Long) -> Boolean, - onPlayerControlsScrubFinished: (Long) -> Boolean, onControllerReady: (PlayerEngineController) -> Unit, onSnapshot: (PlayerPlaybackSnapshot) -> Unit, onError: (String?) -> Unit, diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt index 1737c08e..bf14a452 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt @@ -14,13 +14,11 @@ actual object ThemeSettingsStorage { private const val selectedThemeKey = "selected_theme" private const val amoledEnabledKey = "amoled_enabled" private const val liquidGlassNativeTabBarEnabledKey = "liquid_glass_native_tab_bar_enabled" - private const val desktopNavigationLayoutKey = "desktop_navigation_layout" private const val selectedAppLanguageKey = "selected_app_language" private val profileScopedSyncKeys = listOf( selectedThemeKey, amoledEnabledKey, liquidGlassNativeTabBarEnabledKey, - desktopNavigationLayoutKey, ) actual fun loadSelectedTheme(): String? = @@ -61,16 +59,6 @@ actual object ThemeSettingsStorage { ) } - actual fun loadDesktopNavigationLayout(): String? = - NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(desktopNavigationLayoutKey)) - - actual fun saveDesktopNavigationLayout(layoutName: String) { - NSUserDefaults.standardUserDefaults.setObject( - layoutName, - forKey = ProfileScopedKey.of(desktopNavigationLayoutKey), - ) - } - actual fun loadSelectedAppLanguage(): String? { val value = NSUserDefaults.standardUserDefaults.stringForKey(selectedAppLanguageKey) if (value != null) return value @@ -99,7 +87,6 @@ actual object ThemeSettingsStorage { loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) } loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) } loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) } - loadDesktopNavigationLayout()?.let { put(desktopNavigationLayoutKey, encodeSyncString(it)) } } actual fun replaceFromSyncPayload(payload: JsonObject) { @@ -110,7 +97,6 @@ actual object ThemeSettingsStorage { payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme) payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled) payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled) - payload.decodeSyncString(desktopNavigationLayoutKey)?.let(::saveDesktopNavigationLayout) applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 412fcceb..39ae4c82 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,7 +18,6 @@ kermit = "2.0.5" junit = "4.13.2" kotlin = "2.3.0" kotlinx-serialization = "1.8.1" -kotlinx-coroutines = "1.10.2" ktor = "3.4.1" material3 = "1.11.0-alpha07" androidx-media3 = "1.8.0" @@ -54,9 +53,7 @@ coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" } coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } -kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } -ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "androidx-media3" } diff --git a/scripts/set-version.sh b/scripts/set-version.sh deleted file mode 100755 index 04a87aad..00000000 --- a/scripts/set-version.sh +++ /dev/null @@ -1,218 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -BASE_VERSION_FILE="$ROOT_DIR/iosApp/Configuration/Version.xcconfig" -DESKTOP_VERSION_FILE="$ROOT_DIR/composeApp/Configuration/DesktopVersion.properties" - -BASE_VERSION="" -BASE_CODE="" -DESKTOP_VERSION="" -DESKTOP_CODE="" -DRY_RUN=false -SHOW=false - -usage() { - cat <<'EOF' -Usage: - ./scripts/set-version.sh --desktop 0.1.0 --desktop-code 1 - ./scripts/set-version.sh --base 0.2.4 --base-code 75 - ./scripts/set-version.sh --base 0.2.4 --base-code 75 --desktop 0.1.0 --desktop-code 1 - ./scripts/set-version.sh --show - -Options: - --base VERSION Set the upstream/mobile Nuvio version. - --base-code CODE Set the upstream/mobile build code. - --desktop VERSION Set the desktop app release version. - --desktop-code CODE Set the desktop app build code. - --dry-run Print changes without writing files. - --show Print current configured versions. - -h, --help Show this help. - -The base version is stored in iosApp/Configuration/Version.xcconfig. -The desktop version is stored in composeApp/Configuration/DesktopVersion.properties. -EOF -} - -die() { - echo "error: $*" >&2 - exit 1 -} - -read_key() { - local file="$1" - local key="$2" - - [[ -f "$file" ]] || return 0 - awk -F '=' -v target="$key" ' - { - raw_key = $1 - gsub(/^[[:space:]]+|[[:space:]]+$/, "", raw_key) - if (raw_key == target) { - sub(/^[^=]*=/, "", $0) - gsub(/^[[:space:]]+|[[:space:]]+$/, "", $0) - print $0 - exit - } - } - ' "$file" -} - -validate_version() { - local label="$1" - local value="$2" - - [[ "$value" =~ ^[0-9]+(\.[0-9]+){1,3}([._-][0-9A-Za-z][0-9A-Za-z._-]*)?$ ]] || - die "$label must look like 0.1.0 or 0.1.0-alpha.1" -} - -validate_code() { - local label="$1" - local value="$2" - - [[ "$value" =~ ^[0-9]+$ ]] || die "$label must be a positive integer" - (( 10#$value > 0 )) || die "$label must be greater than 0" -} - -write_key() { - local file="$1" - local key="$2" - local value="$3" - - mkdir -p "$(dirname "$file")" - local tmp - tmp="$(mktemp "${TMPDIR:-/tmp}/nuvio-version.XXXXXX")" - - if [[ -f "$file" ]]; then - awk -v key="$key" -v value="$value" ' - BEGIN { seen = 0 } - { - raw_key = $0 - sub(/[[:space:]]*=.*/, "", raw_key) - gsub(/^[[:space:]]+|[[:space:]]+$/, "", raw_key) - if (raw_key == key) { - print key "=" value - seen = 1 - next - } - print - } - END { - if (!seen) print key "=" value - } - ' "$file" > "$tmp" - else - printf '%s=%s\n' "$key" "$value" > "$tmp" - fi - - mv "$tmp" "$file" -} - -print_current_versions() { - local base_version base_code desktop_version desktop_code - base_version="$(read_key "$BASE_VERSION_FILE" "MARKETING_VERSION")" - base_code="$(read_key "$BASE_VERSION_FILE" "CURRENT_PROJECT_VERSION")" - desktop_version="$(read_key "$DESKTOP_VERSION_FILE" "VERSION_NAME")" - desktop_code="$(read_key "$DESKTOP_VERSION_FILE" "VERSION_CODE")" - - echo "Base/mobile: ${base_version:-unset} (${base_code:-unset})" - echo "Desktop: ${desktop_version:-unset} (${desktop_code:-unset})" -} - -queue_change() { - local file="$1" - local key="$2" - local value="$3" - local current - current="$(read_key "$file" "$key")" - - if [[ "$current" == "$value" ]]; then - echo "unchanged: $key=$value" - return 0 - fi - - echo "set: $key ${current:-unset} -> $value" - if [[ "$DRY_RUN" == false ]]; then - write_key "$file" "$key" "$value" - fi -} - -while [[ $# -gt 0 ]]; do - case "$1" in - --base) - [[ $# -ge 2 ]] || die "--base requires a value" - BASE_VERSION="$2" - shift 2 - ;; - --base-code) - [[ $# -ge 2 ]] || die "--base-code requires a value" - BASE_CODE="$2" - shift 2 - ;; - --desktop) - [[ $# -ge 2 ]] || die "--desktop requires a value" - DESKTOP_VERSION="$2" - shift 2 - ;; - --desktop-code) - [[ $# -ge 2 ]] || die "--desktop-code requires a value" - DESKTOP_CODE="$2" - shift 2 - ;; - --dry-run) - DRY_RUN=true - shift - ;; - --show) - SHOW=true - shift - ;; - -h|--help) - usage - exit 0 - ;; - *) - die "unknown argument: $1" - ;; - esac -done - -if [[ "$SHOW" == true ]]; then - print_current_versions - if [[ -z "$BASE_VERSION$BASE_CODE$DESKTOP_VERSION$DESKTOP_CODE" ]]; then - exit 0 - fi - echo -fi - -[[ -n "$BASE_VERSION$BASE_CODE$DESKTOP_VERSION$DESKTOP_CODE" ]] || - die "nothing to change; pass --base/--base-code/--desktop/--desktop-code or --show" - -if [[ -n "$BASE_VERSION" ]]; then - validate_version "base version" "$BASE_VERSION" -fi -if [[ -n "$BASE_CODE" ]]; then - validate_code "base code" "$BASE_CODE" -fi -if [[ -n "$DESKTOP_VERSION" ]]; then - validate_version "desktop version" "$DESKTOP_VERSION" -fi -if [[ -n "$DESKTOP_CODE" ]]; then - validate_code "desktop code" "$DESKTOP_CODE" -fi - -if [[ -n "$BASE_VERSION$BASE_CODE" ]]; then - echo "Base/mobile version file: $BASE_VERSION_FILE" - [[ -z "$BASE_VERSION" ]] || queue_change "$BASE_VERSION_FILE" "MARKETING_VERSION" "$BASE_VERSION" - [[ -z "$BASE_CODE" ]] || queue_change "$BASE_VERSION_FILE" "CURRENT_PROJECT_VERSION" "$BASE_CODE" -fi - -if [[ -n "$DESKTOP_VERSION$DESKTOP_CODE" ]]; then - echo "Desktop version file: $DESKTOP_VERSION_FILE" - [[ -z "$DESKTOP_VERSION" ]] || queue_change "$DESKTOP_VERSION_FILE" "VERSION_NAME" "$DESKTOP_VERSION" - [[ -z "$DESKTOP_CODE" ]] || queue_change "$DESKTOP_VERSION_FILE" "VERSION_CODE" "$DESKTOP_CODE" -fi - -echo -print_current_versions